Compare commits
399 commits
Author | SHA1 | Date | |
---|---|---|---|
|
72fa575fee | ||
|
01e239e657 | ||
|
79ab0a3786 | ||
|
188b41ef53 | ||
|
c249abdb10 | ||
|
2b79ab0313 | ||
|
db66a885fb | ||
|
79a66ff140 | ||
|
4601951bbf | ||
|
e1db89311d | ||
|
f7966996d7 | ||
|
a7fa19f2e4 | ||
|
7b5d583b76 | ||
|
b765c02166 | ||
|
0f9c32161c | ||
|
63963b6b19 | ||
|
d9deb29730 | ||
|
e65b73dc73 | ||
|
0be229cdaa | ||
|
4504617c1a | ||
|
17039e620e | ||
|
fbf505cff0 | ||
|
3966f9fa40 | ||
|
387ddac892 | ||
|
e3b94d7fb5 | ||
|
121a21d900 | ||
|
eab66ee5fc | ||
|
83e0a57338 | ||
|
b643e0520f | ||
|
f88aa393c5 | ||
|
eca7bcda0a | ||
|
6b4179fa01 | ||
|
e29f371232 | ||
|
b7de165b76 | ||
|
f49c0b1c4c | ||
|
2fcd45819f | ||
|
d06077c432 | ||
|
5b6d17c9b9 | ||
|
c046dd04e7 | ||
|
6384f4667b | ||
|
82de4558de | ||
|
ec7d336bc2 | ||
|
6d57d9d6a1 | ||
|
7fbf49a0f0 | ||
|
76818287d6 | ||
|
646fff775c | ||
|
ec8e0b4a8b | ||
|
d1c4efc143 | ||
|
c2ea61c82e | ||
|
6e01bb7d94 | ||
|
94f12dfc29 | ||
|
9421cfd422 | ||
|
8193a41082 | ||
|
1b20d3704f | ||
|
9c0a2f8b13 | ||
|
74a0947fdb | ||
|
984e5bc415 | ||
|
7f1cc6bf8f | ||
|
0ce5d3ba26 | ||
|
8d3d7419cd | ||
|
6b47fd9e35 | ||
|
25808c7281 | ||
|
2f37c666fb | ||
|
4f3bacb182 | ||
|
71033ecc3d | ||
|
f80139456e | ||
|
5198add7aa | ||
|
096430f1cd | ||
|
8d14c09dc6 | ||
|
0adffb98a6 | ||
|
deec706f14 | ||
|
ceb84bfc3c | ||
|
401f70c7ee | ||
|
0233a62f91 | ||
|
4753eef550 | ||
|
d159e0a1ca | ||
|
52d6ea60f3 | ||
|
2a668d18d3 | ||
|
b9f50883a9 | ||
|
680c0f5564 | ||
|
b8d652eb1a | ||
|
884a479de6 | ||
|
2bef7a2100 | ||
|
c6d54665ac | ||
|
24e94302d9 | ||
|
8372386abd | ||
|
23f0669f7a | ||
|
bfb9e3c4fb | ||
|
1b888e0c04 | ||
|
4e60f0fe6a | ||
|
0eb7c3bf47 | ||
|
de167b162c | ||
|
d03ae0e93d | ||
|
47c584d0a2 | ||
|
e338880609 | ||
|
1dd37bc56d | ||
|
226bb14716 | ||
|
68d43f7af9 | ||
|
5cc89b6795 | ||
|
f5d9839a70 | ||
|
57497f9542 | ||
|
8cf0d9c02c | ||
|
bdcf369b3d | ||
|
69eb4d6b0a | ||
|
d11270a340 | ||
|
1b69a89c59 | ||
|
038a80c25e | ||
|
9ece6682ef | ||
|
b0b06ec945 | ||
|
41e1989345 | ||
|
34d902062f | ||
|
5b7e2a6f2f | ||
|
ed9ff51b68 | ||
|
e36db46eb6 | ||
|
83ffea7fa0 | ||
|
3dd0a9b8dc | ||
|
427f4980b9 | ||
|
0bf67639c1 | ||
|
e34a58d0ef | ||
|
6d547040ef | ||
|
216f36f47b | ||
|
a352a3518a | ||
|
1a3f3f0ec6 | ||
|
306ca173c6 | ||
|
1d89dea72e | ||
|
22de459a72 | ||
|
5be1f2451c | ||
|
3a53353ed8 | ||
|
56cad35b19 | ||
|
ff84c7aa4d | ||
|
4c6169d55d | ||
|
ab1b2922cc | ||
|
9401d047d6 | ||
|
54b46b61db | ||
|
235a3ee143 | ||
|
f4accd5064 | ||
|
bc00ae1963 | ||
|
775d86cb00 | ||
|
90e564870d | ||
|
62c26e78ba | ||
|
69002fdcbf | ||
|
4acf08d9e9 | ||
|
df7fee2018 | ||
|
c64c7c77ae | ||
|
e788b90b04 | ||
|
66f049cc39 | ||
|
ff07c447ee | ||
|
d33a556732 | ||
|
737d76176a | ||
|
8e6ddc1993 | ||
|
b85afa1ea6 | ||
|
6b8cc591cc | ||
|
859a4b37e5 | ||
|
3caa33b9bf | ||
|
e932467ac9 | ||
|
aac4514577 | ||
|
21f5073717 | ||
|
64d1a2f536 | ||
|
e4e059cb13 | ||
|
feab841609 | ||
|
3e7d236c6d | ||
|
289730e24a | ||
|
a1becfdc83 | ||
|
0bf0b425ee | ||
|
10994c532f | ||
|
ae70c2dbe4 | ||
|
cdb1ffd1da | ||
|
d467fdf158 | ||
|
643d025381 | ||
|
ee485e0488 | ||
|
5204b3b752 | ||
|
45ca9c4c2b | ||
|
71fd25870d | ||
|
dd797c8145 | ||
|
3870749e5e | ||
|
87b3585c44 | ||
|
bf213cd0b0 | ||
|
815500ab78 | ||
|
4aad0338bf | ||
|
711cb387a5 | ||
|
e3323d11c8 | ||
|
076c4ae2f2 | ||
|
530a36fc53 | ||
|
8207a25fa9 | ||
|
7b84dafea7 | ||
|
ed60aea39e | ||
|
8f02449ee8 | ||
|
1e37f60d50 | ||
|
c18987705c | ||
|
7db4b699e2 | ||
|
26ba79ff02 | ||
|
b232e7efd7 | ||
|
64dcb56793 | ||
|
273267343a | ||
|
27e82f0409 | ||
|
167971771e | ||
|
2275a288b9 | ||
|
f96f8268f0 | ||
|
74f3ded250 | ||
|
c1609cdb90 | ||
|
e96e657430 | ||
|
f404f7b928 | ||
|
7dda53146d | ||
|
e2fde518ca | ||
|
c75507ca8f | ||
|
82e7dcd3f3 | ||
|
361c887e2c | ||
|
13ca890709 | ||
|
c6323dba8c | ||
|
dcc6f036c6 | ||
|
d7d44cb4e1 | ||
|
2a496bd000 | ||
|
15047b7288 | ||
|
d1afa44a2e | ||
|
ac40b2f733 | ||
|
e2b2ba4577 | ||
|
cc75be1eb5 | ||
|
221d0d7dbb | ||
|
cc9705447d | ||
|
06968e7341 | ||
|
62f9b2948e | ||
|
a8afa18ab2 | ||
|
b291b89904 | ||
|
96eb800eaa | ||
|
36f4e30595 | ||
|
177cbf2e57 | ||
|
334d499fb3 | ||
|
322d0d618a | ||
|
c9dc8d5a90 | ||
|
d48262a6df | ||
|
83f230ddaf | ||
|
efe669b874 | ||
|
aa72bcba50 | ||
|
8626aa12cc | ||
|
264bef03b1 | ||
|
e0c165ff1e | ||
|
2986f83121 | ||
|
3d8b8ecc93 | ||
|
5d4ebb59c7 | ||
|
2b5318e7a6 | ||
|
baf1d76475 | ||
|
94bb566e4f | ||
|
d3f312a1e2 | ||
|
ebeb45ac5a | ||
|
3dc515c249 | ||
|
10a415a7ec | ||
|
a8c5468f65 | ||
|
43ba111e21 | ||
|
299686c13e | ||
|
dff01a6136 | ||
|
8f03da0ec1 | ||
|
142c5d6cec | ||
|
526db318c4 | ||
|
fe1f821422 | ||
|
2fde648519 | ||
|
3e21ecb53c | ||
|
3ba29aaa2c | ||
|
c60d135060 | ||
|
4c48733a3a | ||
|
f2474798bb | ||
|
9c9fa8bf62 | ||
|
3981b6dddb | ||
|
da3e5d0606 | ||
|
51c46621d8 | ||
|
21a1c738d1 | ||
|
0814ec28dc | ||
|
c7729a0432 | ||
|
a408f0f9ea | ||
|
e9b03c9350 | ||
|
65ec6b44e1 | ||
|
21efde71f7 | ||
|
8755f1706c | ||
|
41138e4ab2 | ||
|
0860d1db1f | ||
|
b54de10663 | ||
|
78e59b749b | ||
|
20fec65e6b | ||
|
cf53730f6c | ||
|
dbdbcfd100 | ||
|
54eb2db14d | ||
|
e65086b635 | ||
|
b753d41964 | ||
|
5d5a8536c8 | ||
|
9580cffb3d | ||
|
1aee7ed125 | ||
|
989d7eb2fc | ||
|
ba8aebaa6f | ||
|
949f13bf66 | ||
|
f92f7b13cb | ||
|
98790ee371 | ||
|
a9733c30cf | ||
|
d3f935f693 | ||
|
3eb3146ae9 | ||
|
229607a5ab | ||
|
d476c3b2f7 | ||
|
6946d3b785 | ||
|
e0372979d9 | ||
|
639770be4d | ||
|
b0b166e827 | ||
|
e2237653bb | ||
|
77823a382b | ||
|
b6d17a9594 | ||
|
e1e05e5f29 | ||
|
67dbc9b22b | ||
|
3f5fd6e2d2 | ||
|
3a7554abe8 | ||
|
e350b7ce8a | ||
|
1a61128dfc | ||
|
ddabab041a | ||
|
2ba840634b | ||
|
ac9c53cfff | ||
|
1a4845aca8 | ||
|
7c0e69cf41 | ||
|
cdaa13a260 | ||
|
0dcfd1809d | ||
|
ad6c8f30bc | ||
|
86c76b0442 | ||
|
43176ed7ea | ||
|
64772aa203 | ||
|
40b9c08c86 | ||
|
ea81e2c839 | ||
|
02fb079a9f | ||
|
0746ec8567 | ||
|
7e5d60043d | ||
|
af875b4d87 | ||
|
8dd7b40c02 | ||
|
8834253502 | ||
|
7feea370ed | ||
|
680f0d1e20 | ||
|
bc53300e33 | ||
|
af0927cf5c | ||
|
ee665c0c68 | ||
|
83765d5cbc | ||
|
77cc1cc816 | ||
|
118eb732f4 | ||
|
99d72881cf | ||
|
fc5a79a6bc | ||
|
a0f1e1821f | ||
|
f84b4b0f74 | ||
|
7a84d27dca | ||
|
3e6669828c | ||
|
bbcb61bc53 | ||
|
8684ff04a4 | ||
|
93d5fd152d | ||
|
6903dd4349 | ||
|
b5021f2b0c | ||
|
29c898867a | ||
|
17614b5e02 | ||
|
950090c0d7 | ||
|
01c920b253 | ||
|
4c1678f91e | ||
|
4b33c51ece | ||
|
99d17e5e97 | ||
|
6347301867 | ||
|
7f83bb2706 | ||
|
02383768ed | ||
|
f85241e037 | ||
|
a080e51aaa | ||
|
57b12f31c9 | ||
|
c58eedba7d | ||
|
9767910b1f | ||
|
ac1b947b18 | ||
|
a5c80b98e7 | ||
|
7b5326ada9 | ||
|
2c644dd262 | ||
|
7687341512 | ||
|
beb964a9f1 | ||
|
42c7e22b98 | ||
|
4f2b17ddb1 | ||
|
11266dd87e | ||
|
de0c1085b4 | ||
|
2cf7693a8e | ||
|
915351c4af | ||
|
0a19dc1ec2 | ||
|
baaf0580f5 | ||
|
4680e2e046 | ||
|
c3ae4e6d3c | ||
|
dd88083b2a | ||
|
fd44bc5707 | ||
|
9ee83ae885 | ||
|
a0e936ee1b | ||
|
df7be46417 | ||
|
b190a1508b | ||
|
27f68ef0cf | ||
|
e91748c0bc | ||
|
414d5b0a1c | ||
|
c4b124e37c | ||
|
f4977c7a34 | ||
|
fc8e209def | ||
|
e963755393 | ||
|
2288ccf2a2 | ||
|
6b336e22aa | ||
|
cbc2427475 | ||
|
276304d5b8 | ||
|
2ea235f0c4 | ||
|
ebdb932090 | ||
|
4c0fcdf7c6 | ||
|
9ed2687543 | ||
|
530439772d |
106 changed files with 3942 additions and 551 deletions
|
@ -1,2 +1 @@
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.git
|
|
||||||
|
|
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
charset = utf-8
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- [ ] I have signed the [CLA](https://phabricator.write.as/L1)
|
- [ ] I have signed the [CLA](https://todo.musing.studio/L1)
|
||||||
|
|
19
.github/workflows/docker-publish.yml
vendored
19
.github/workflows/docker-publish.yml
vendored
|
@ -1,4 +1,4 @@
|
||||||
name: Build container image, publish as Github-package
|
name: Build container image, publish as GitHub-package
|
||||||
|
|
||||||
# This workflow uses actions that are not certified by GitHub.
|
# This workflow uses actions that are not certified by GitHub.
|
||||||
# They are provided by a third-party and are governed by
|
# They are provided by a third-party and are governed by
|
||||||
|
@ -28,13 +28,21 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up QEMU for cross-building
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3.0.0
|
||||||
|
|
||||||
|
# Set up Docker Buildx
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3.0.0
|
||||||
|
|
||||||
# Login against a Docker registry except on PR
|
# Login against a Docker registry except on PR
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2.0.0
|
uses: docker/login-action@v3.0.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
@ -44,7 +52,7 @@ jobs:
|
||||||
# https://github.com/docker/metadata-action
|
# https://github.com/docker/metadata-action
|
||||||
- name: Extract Docker metadata
|
- name: Extract Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4.0.1
|
uses: docker/metadata-action@v4.6.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ghcr.io/${{ github.repository }}
|
ghcr.io/${{ github.repository }}
|
||||||
|
@ -53,9 +61,10 @@ jobs:
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker images
|
- name: Build and push Docker images
|
||||||
uses: docker/build-push-action@v3.0.0
|
uses: docker/build-push-action@v5.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,6 +3,7 @@ node_modules
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
static/local/custom.css
|
||||||
build
|
build
|
||||||
tmp
|
tmp
|
||||||
*.ini
|
*.ini
|
||||||
|
|
|
@ -18,11 +18,11 @@ First, you'll want to clone the WriteFreely repo, install development dependenci
|
||||||
|
|
||||||
### Starting development
|
### Starting development
|
||||||
|
|
||||||
Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://phabricator.write.as/tag/write_freely/) to see where the project is today and where it's headed.
|
Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://todo.musing.studio/tag/writefreely/) to see where the project is today and where it's headed.
|
||||||
|
|
||||||
When you find something you want to work on, start a new topic on the forum or jump into an existing discussion, if there is one. The team will respond and continue the conversation there.
|
When you find something you want to work on, start a new topic on the forum or jump into an existing discussion, if there is one. The team will respond and continue the conversation there.
|
||||||
|
|
||||||
Lastly, **before submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
|
Lastly, **before submitting any code**, please sign our [contributor's agreement](https://todo.musing.studio/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://todo.musing.studio/w/writefreely/cla/).
|
||||||
|
|
||||||
### Branching
|
### Branching
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ We value reliable, readable, and maintainable code over all else in our work. To
|
||||||
* Write code for other humans, not computers.
|
* Write code for other humans, not computers.
|
||||||
* The less complexity, the better. The more someone can understand code just by looking at it, the better.
|
* The less complexity, the better. The more someone can understand code just by looking at it, the better.
|
||||||
* Functionality, readability, and maintainability over senseless elegance.
|
* Functionality, readability, and maintainability over senseless elegance.
|
||||||
* Only abstract when necessary.
|
* Only abstract when necessary.
|
||||||
* Keep an eye to the future, but don't pre-optimize at the expense of today's simplicity.
|
* Keep an eye to the future, but don't pre-optimize at the expense of today's simplicity.
|
||||||
|
|
||||||
#### Code guidelines
|
#### Code guidelines
|
||||||
|
@ -96,4 +96,4 @@ Beyond that, we prioritize pull requests in this order:
|
||||||
2. Superficial changes and improvements that don't adversely impact users
|
2. Superficial changes and improvements that don't adversely impact users
|
||||||
3. New features and changes that have been discussed before with the team
|
3. New features and changes that have been discussed before with the team
|
||||||
|
|
||||||
Any pull requests that haven't previously been discussed with the team may be extensively delayed or closed, especially if they require a wider consideration before integrating into the project. When in doubt, please reach out [on the forum](https://discuss.write.as) before submitting a pull request.
|
Any pull requests that haven't previously been discussed with the team may be extensively delayed or closed, especially if they require a wider consideration before integrating into the project. When in doubt, please reach out [on the forum](https://discuss.write.as) before submitting a pull request.
|
||||||
|
|
31
Dockerfile
31
Dockerfile
|
@ -1,21 +1,27 @@
|
||||||
# Build image
|
# Build image
|
||||||
FROM golang:1.15-alpine as build
|
FROM golang:1.21-alpine3.18 as build
|
||||||
|
|
||||||
RUN apk add --update nodejs npm make g++ git
|
LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely"
|
||||||
RUN npm install -g less less-plugin-clean-css
|
LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing."
|
||||||
RUN go get -u github.com/go-bindata/go-bindata/...
|
|
||||||
|
RUN apk -U upgrade \
|
||||||
|
&& apk add --no-cache nodejs npm make g++ git \
|
||||||
|
&& npm install -g less less-plugin-clean-css \
|
||||||
|
&& mkdir -p /go/src/github.com/writefreely/writefreely
|
||||||
|
|
||||||
RUN mkdir -p /go/src/github.com/writefreely/writefreely
|
|
||||||
WORKDIR /go/src/github.com/writefreely/writefreely
|
WORKDIR /go/src/github.com/writefreely/writefreely
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN cat ossl_legacy.cnf > /etc/ssl/openssl.cnf
|
||||||
|
|
||||||
ENV GO111MODULE=on
|
ENV GO111MODULE=on
|
||||||
|
ENV NODE_OPTIONS=--openssl-legacy-provider
|
||||||
|
|
||||||
RUN make build \
|
RUN make build \
|
||||||
&& make ui
|
&& make ui \
|
||||||
RUN mkdir /stage && \
|
&& mkdir /stage \
|
||||||
cp -R /go/bin \
|
&& cp -R /go/bin \
|
||||||
/go/src/github.com/writefreely/writefreely/templates \
|
/go/src/github.com/writefreely/writefreely/templates \
|
||||||
/go/src/github.com/writefreely/writefreely/static \
|
/go/src/github.com/writefreely/writefreely/static \
|
||||||
/go/src/github.com/writefreely/writefreely/pages \
|
/go/src/github.com/writefreely/writefreely/pages \
|
||||||
|
@ -24,9 +30,11 @@ RUN mkdir /stage && \
|
||||||
/stage
|
/stage
|
||||||
|
|
||||||
# Final image
|
# Final image
|
||||||
FROM alpine:3
|
FROM alpine:3.18.4
|
||||||
|
|
||||||
|
RUN apk -U upgrade \
|
||||||
|
&& apk add --no-cache openssl ca-certificates
|
||||||
|
|
||||||
RUN apk add --no-cache openssl ca-certificates
|
|
||||||
COPY --from=build --chown=daemon:daemon /stage /go
|
COPY --from=build --chown=daemon:daemon /stage /go
|
||||||
|
|
||||||
WORKDIR /go
|
WORKDIR /go
|
||||||
|
@ -35,3 +43,6 @@ EXPOSE 8080
|
||||||
USER daemon
|
USER daemon
|
||||||
|
|
||||||
ENTRYPOINT ["cmd/writefreely/writefreely"]
|
ENTRYPOINT ["cmd/writefreely/writefreely"]
|
||||||
|
|
||||||
|
HEALTHCHECK --start-period=5s --interval=15s --timeout=5s \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
|
34
Dockerfile.prod
Normal file
34
Dockerfile.prod
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
FROM golang:alpine AS build
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely"
|
||||||
|
LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing."
|
||||||
|
|
||||||
|
RUN apk update --no-cache && \
|
||||||
|
apk upgrade --no-cache && \
|
||||||
|
apk add --no-cache nodejs npm make g++ git sqlite-dev patch && \
|
||||||
|
npm install -g less less-plugin-clean-css && \
|
||||||
|
mkdir -p /go/src/github.com/writefreely/writefreely
|
||||||
|
|
||||||
|
COPY . /go/src/github.com/writefreely/writefreely
|
||||||
|
WORKDIR /go/src/github.com/writefreely/writefreely
|
||||||
|
ENV NODE_OPTIONS=--openssl-legacy-provider
|
||||||
|
RUN cat ossl_legacy.cnf >> /etc/ssl/openssl.cnf && \
|
||||||
|
make build && \
|
||||||
|
make ui
|
||||||
|
|
||||||
|
FROM alpine
|
||||||
|
|
||||||
|
RUN apk update --no-cache && \
|
||||||
|
apk upgrade --no-cache && \
|
||||||
|
apk add --no-cache openssl ca-certificates && \
|
||||||
|
mkdir /usr/share/writefreely
|
||||||
|
|
||||||
|
COPY --from=build /go/src/github.com/writefreely/writefreely/cmd/writefreely/writefreely /usr/bin
|
||||||
|
COPY --from=build /go/src/github.com/writefreely/writefreely/pages /usr/share/writefreely/pages
|
||||||
|
COPY --from=build /go/src/github.com/writefreely/writefreely/static /usr/share/writefreely/static
|
||||||
|
COPY --from=build /go/src/github.com/writefreely/writefreely/templates /usr/share/writefreely/templates
|
||||||
|
|
||||||
|
ENV WRITEFREELY_DOCKER=True
|
||||||
|
ENV HOME=/data
|
||||||
|
WORKDIR /data
|
||||||
|
CMD ["/usr/bin/writefreely"]
|
84
Makefile
84
Makefile
|
@ -1,5 +1,5 @@
|
||||||
GITREV=`git describe | cut -c 2-`
|
GITREV=`git describe | cut -c 2-`
|
||||||
LDFLAGS=-ldflags="-X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'"
|
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)' -extldflags '-static'"
|
||||||
|
|
||||||
GOCMD=go
|
GOCMD=go
|
||||||
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
||||||
|
@ -14,50 +14,56 @@ TMPBIN=./tmp
|
||||||
|
|
||||||
all : build
|
all : build
|
||||||
|
|
||||||
ci: ci-assets deps
|
ci: deps
|
||||||
cd cmd/writefreely; $(GOBUILD) -v
|
cd cmd/writefreely; $(GOBUILD) -v
|
||||||
|
|
||||||
build: assets deps
|
build: deps
|
||||||
cd cmd/writefreely; $(GOBUILD) -v -tags='sqlite'
|
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite'
|
||||||
|
|
||||||
build-no-sqlite: assets-no-sqlite deps-no-sqlite
|
build-no-sqlite: deps-no-sqlite
|
||||||
cd cmd/writefreely; $(GOBUILD) -v -o $(BINARY_NAME)
|
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME)
|
||||||
|
|
||||||
build-linux: deps
|
build-linux: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely
|
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||||
|
|
||||||
build-windows: deps
|
build-windows: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely
|
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||||
|
|
||||||
build-darwin: deps
|
build-darwin: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely
|
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||||
|
|
||||||
|
build-darwin-arm64: deps
|
||||||
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
|
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||||
|
fi
|
||||||
|
xgo --targets=darwin/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||||
|
|
||||||
build-arm6: deps
|
build-arm6: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely
|
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||||
|
|
||||||
build-arm7: deps
|
build-arm7: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely
|
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||||
|
|
||||||
build-arm64: deps
|
build-arm64: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='sqlite' -go go-1.15.x -out writefreely ./cmd/writefreely
|
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||||
|
|
||||||
build-docker :
|
build-docker :
|
||||||
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
||||||
|
@ -65,8 +71,8 @@ build-docker :
|
||||||
test:
|
test:
|
||||||
$(GOTEST) -v ./...
|
$(GOTEST) -v ./...
|
||||||
|
|
||||||
run: dev-assets
|
run:
|
||||||
$(GOINSTALL) -tags='sqlite' ./...
|
$(GOINSTALL) -tags='netgo sqlite' ./...
|
||||||
$(BINARY_NAME) --debug
|
$(BINARY_NAME) --debug
|
||||||
|
|
||||||
deps :
|
deps :
|
||||||
|
@ -81,11 +87,12 @@ install : build
|
||||||
cmd/writefreely/$(BINARY_NAME) --init-db
|
cmd/writefreely/$(BINARY_NAME) --init-db
|
||||||
cd less/; $(MAKE) install $(MFLAGS)
|
cd less/; $(MAKE) install $(MFLAGS)
|
||||||
|
|
||||||
release : clean ui assets
|
release : clean ui
|
||||||
mkdir -p $(BUILDPATH)
|
mkdir -p $(BUILDPATH)
|
||||||
cp -r templates $(BUILDPATH)
|
rsync -av --exclude=".*" templates $(BUILDPATH)
|
||||||
cp -r pages $(BUILDPATH)
|
rsync -av --exclude=".*" pages $(BUILDPATH)
|
||||||
cp -r static $(BUILDPATH)
|
rsync -av --exclude=".*" static $(BUILDPATH)
|
||||||
|
rm -r $(BUILDPATH)/static/local
|
||||||
scripts/invalidate-css.sh $(BUILDPATH)
|
scripts/invalidate-css.sh $(BUILDPATH)
|
||||||
mkdir $(BUILDPATH)/keys
|
mkdir $(BUILDPATH)/keys
|
||||||
$(MAKE) build-linux
|
$(MAKE) build-linux
|
||||||
|
@ -108,6 +115,10 @@ release : clean ui assets
|
||||||
mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
|
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
|
||||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||||
|
$(MAKE) build-darwin-arm64
|
||||||
|
mv build/$(BINARY_NAME)-darwin-arm64 $(BUILDPATH)/$(BINARY_NAME)
|
||||||
|
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_arm64.tar.gz -C build $(BINARY_NAME)
|
||||||
|
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||||
$(MAKE) build-windows
|
$(MAKE) build-windows
|
||||||
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
|
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
|
||||||
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
|
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
|
||||||
|
@ -133,39 +144,16 @@ ui : force_look
|
||||||
cd less/; $(MAKE) $(MFLAGS)
|
cd less/; $(MAKE) $(MFLAGS)
|
||||||
cd prose/; $(MAKE) $(MFLAGS)
|
cd prose/; $(MAKE) $(MFLAGS)
|
||||||
|
|
||||||
assets : generate
|
|
||||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
|
||||||
|
|
||||||
assets-no-sqlite: generate
|
|
||||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql
|
|
||||||
|
|
||||||
dev-assets : generate
|
|
||||||
go-bindata -pkg writefreely -ignore=\\.gitignore -debug -tags="!wflib" schema.sql sqlite.sql
|
|
||||||
|
|
||||||
lib-assets : generate
|
|
||||||
go-bindata -pkg writefreely -ignore=\\.gitignore -o bindata-lib.go -tags="wflib" schema.sql
|
|
||||||
|
|
||||||
generate :
|
|
||||||
@hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
|
||||||
$(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
$(TMPBIN):
|
$(TMPBIN):
|
||||||
mkdir -p $(TMPBIN)
|
mkdir -p $(TMPBIN)
|
||||||
|
|
||||||
$(TMPBIN)/go-bindata: deps $(TMPBIN)
|
|
||||||
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
|
|
||||||
|
|
||||||
$(TMPBIN)/xgo: deps $(TMPBIN)
|
$(TMPBIN)/xgo: deps $(TMPBIN)
|
||||||
$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
|
$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
|
||||||
|
|
||||||
ci-assets : $(TMPBIN)/go-bindata
|
|
||||||
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
|
||||||
|
|
||||||
clean :
|
clean :
|
||||||
-rm -rf build
|
-rm -rf build
|
||||||
-rm -rf tmp
|
-rm -rf tmp
|
||||||
cd less/; $(MAKE) clean $(MFLAGS)
|
cd less/; $(MAKE) clean $(MFLAGS)
|
||||||
|
|
||||||
force_look :
|
force_look :
|
||||||
true
|
true
|
||||||
|
|
|
@ -69,6 +69,7 @@ For common platforms, start with our [pre-built binaries](https://github.com/wri
|
||||||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||||
|
|
||||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||||
|
* [Nanos Repository](https://repo.ops.city/v2/packages/eyberg/writefreely/show)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
@ -86,4 +87,4 @@ Before contributing anything, please read our [Contributing Guide](https://githu
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright © 2018-2022 [Musing Studio LLC](https://musing.studio) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE).
|
Copyright © 2018-2025 [Musing Studio LLC](https://musing.studio) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE).
|
||||||
|
|
334
account.go
334
account.go
|
@ -13,6 +13,8 @@ package writefreely
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/writefreely/writefreely/mailer"
|
||||||
|
"github.com/writefreely/writefreely/spam"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
@ -323,6 +325,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
To string
|
To string
|
||||||
Message template.HTML
|
Message template.HTML
|
||||||
Flashes []template.HTML
|
Flashes []template.HTML
|
||||||
|
EmailEnabled bool
|
||||||
LoginUsername string
|
LoginUsername string
|
||||||
}{
|
}{
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
|
@ -330,6 +333,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
To: r.FormValue("to"),
|
To: r.FormValue("to"),
|
||||||
Message: template.HTML(""),
|
Message: template.HTML(""),
|
||||||
Flashes: []template.HTML{},
|
Flashes: []template.HTML{},
|
||||||
|
EmailEnabled: app.cfg.Email.Enabled(),
|
||||||
LoginUsername: getTempInfo(app, "login-user", r, w),
|
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -504,7 +508,7 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
// User has no email set, so check if they haven't added a password, either,
|
// User has no email set, so check if they haven't added a password, either,
|
||||||
// so we can return a more helpful error message.
|
// so we can return a more helpful error message.
|
||||||
if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
|
if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
|
||||||
log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
|
log.Info("Tried logging into %s, but no password or email.", signin.Alias)
|
||||||
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -577,7 +581,7 @@ func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser
|
||||||
}
|
}
|
||||||
passIsSet, err := app.db.IsUserPassSet(u.ID)
|
passIsSet, err := app.db.IsUserPassSet(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// TODO: correct error meesage
|
// TODO: correct error message
|
||||||
log.Error("Login: Unable to get user collections: %v", err)
|
log.Error("Login: Unable to get user collections: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -787,6 +791,9 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
||||||
|
|
||||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
log.Error("view articles: %v", err)
|
log.Error("view articles: %v", err)
|
||||||
}
|
}
|
||||||
d := struct {
|
d := struct {
|
||||||
|
@ -822,7 +829,10 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view collections %v", err)
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Error("view collections: %v", err)
|
||||||
return fmt.Errorf("view collections: %v", err)
|
return fmt.Errorf("view collections: %v", err)
|
||||||
}
|
}
|
||||||
d := struct {
|
d := struct {
|
||||||
|
@ -856,11 +866,11 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add collection properties
|
|
||||||
c.Monetization = app.db.GetCollectionAttribute(c.ID, "monetization_pointer")
|
|
||||||
|
|
||||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
log.Error("view edit collection %v", err)
|
log.Error("view edit collection %v", err)
|
||||||
return fmt.Errorf("view edit collection: %v", err)
|
return fmt.Errorf("view edit collection: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -869,12 +879,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
||||||
*UserPage
|
*UserPage
|
||||||
*Collection
|
*Collection
|
||||||
Silenced bool
|
Silenced bool
|
||||||
|
|
||||||
|
config.EmailCfg
|
||||||
|
LetterReplyTo string
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||||
Collection: c,
|
Collection: c,
|
||||||
Silenced: silenced,
|
Silenced: silenced,
|
||||||
|
EmailCfg: app.cfg.Email,
|
||||||
}
|
}
|
||||||
obj.UserPage.CollAlias = c.Alias
|
obj.UserPage.CollAlias = c.Alias
|
||||||
|
if obj.EmailCfg.Enabled() {
|
||||||
|
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
|
||||||
|
}
|
||||||
|
|
||||||
showUserPage(w, "collection", obj)
|
showUserPage(w, "collection", obj)
|
||||||
return nil
|
return nil
|
||||||
|
@ -1038,22 +1055,28 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
||||||
|
|
||||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
log.Error("view stats: %v", err)
|
log.Error("view stats: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
obj := struct {
|
obj := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
VisitsBlog string
|
VisitsBlog string
|
||||||
Collection *Collection
|
Collection *Collection
|
||||||
TopPosts *[]PublicPost
|
TopPosts *[]PublicPost
|
||||||
APFollowers int
|
APFollowers int
|
||||||
Silenced bool
|
EmailEnabled bool
|
||||||
|
EmailSubscribers int
|
||||||
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||||
VisitsBlog: alias,
|
VisitsBlog: alias,
|
||||||
Collection: c,
|
Collection: c,
|
||||||
TopPosts: topPosts,
|
TopPosts: topPosts,
|
||||||
Silenced: silenced,
|
EmailEnabled: app.cfg.Email.Enabled(),
|
||||||
|
Silenced: silenced,
|
||||||
}
|
}
|
||||||
obj.UserPage.CollAlias = c.Alias
|
obj.UserPage.CollAlias = c.Alias
|
||||||
if app.cfg.App.Federation {
|
if app.cfg.App.Federation {
|
||||||
|
@ -1063,14 +1086,79 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
||||||
}
|
}
|
||||||
obj.APFollowers = len(*folls)
|
obj.APFollowers = len(*folls)
|
||||||
}
|
}
|
||||||
|
if obj.EmailEnabled {
|
||||||
|
subs, err := app.db.GetEmailSubscribers(c.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
obj.EmailSubscribers = len(subs)
|
||||||
|
}
|
||||||
|
|
||||||
showUserPage(w, "stats", obj)
|
showUserPage(w, "stats", obj)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleViewSubscribers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
c, err := app.db.GetCollection(vars["collection"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := r.FormValue("filter")
|
||||||
|
|
||||||
|
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||||
|
obj := struct {
|
||||||
|
*UserPage
|
||||||
|
Collection CollectionNav
|
||||||
|
EmailSubs []*EmailSubscriber
|
||||||
|
Followers *[]RemoteUser
|
||||||
|
Silenced bool
|
||||||
|
|
||||||
|
Filter string
|
||||||
|
FederationEnabled bool
|
||||||
|
CanEmailSub bool
|
||||||
|
CanAddSubs bool
|
||||||
|
EmailSubsEnabled bool
|
||||||
|
}{
|
||||||
|
UserPage: NewUserPage(app, r, u, c.DisplayTitle()+" Subscribers", flashes),
|
||||||
|
Collection: CollectionNav{
|
||||||
|
Collection: c,
|
||||||
|
Path: r.URL.Path,
|
||||||
|
SingleUser: app.cfg.App.SingleUser,
|
||||||
|
},
|
||||||
|
Silenced: u.IsSilenced(),
|
||||||
|
Filter: filter,
|
||||||
|
FederationEnabled: app.cfg.App.Federation,
|
||||||
|
CanEmailSub: app.cfg.Email.Enabled(),
|
||||||
|
EmailSubsEnabled: c.EmailSubsEnabled(),
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.Followers, err = app.db.GetAPFollowers(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.EmailSubs, err = app.db.GetEmailSubscribers(c.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if obj.Filter == "" {
|
||||||
|
// Set permission to add email subscribers
|
||||||
|
//obj.CanAddSubs = app.db.GetUserAttribute(c.OwnerID, userAttrCanAddEmailSubs) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
showUserPage(w, "subscribers", obj)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||||
fullUser, err := app.db.GetUserByID(u.ID)
|
fullUser, err := app.db.GetUserByID(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
log.Error("Unable to get user for settings: %s", err)
|
log.Error("Unable to get user for settings: %s", err)
|
||||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||||
}
|
}
|
||||||
|
@ -1153,6 +1241,220 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
token := r.FormValue("t")
|
||||||
|
resetting := false
|
||||||
|
var userID int64 = 0
|
||||||
|
if token != "" {
|
||||||
|
// Show new password page
|
||||||
|
userID = app.db.GetUserFromPasswordReset(token)
|
||||||
|
if userID == 0 {
|
||||||
|
return impart.HTTPError{http.StatusNotFound, ""}
|
||||||
|
}
|
||||||
|
resetting = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
newPass := r.FormValue("new-pass")
|
||||||
|
if newPass == "" {
|
||||||
|
// Send password reset email
|
||||||
|
return handleResetPasswordInit(app, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do actual password reset
|
||||||
|
// Assumes token has been validated above
|
||||||
|
err := doAutomatedPasswordChange(app, userID, newPass)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = app.db.ConsumePasswordResetToken(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err)
|
||||||
|
}
|
||||||
|
addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil)
|
||||||
|
return impart.HTTPError{http.StatusFound, "/login"}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, _ := getSessionFlashes(app, w, r, nil)
|
||||||
|
|
||||||
|
// Show reset password page
|
||||||
|
d := struct {
|
||||||
|
page.StaticPage
|
||||||
|
Flashes []string
|
||||||
|
EmailEnabled bool
|
||||||
|
CSRFField template.HTML
|
||||||
|
Token string
|
||||||
|
IsResetting bool
|
||||||
|
IsSent bool
|
||||||
|
}{
|
||||||
|
StaticPage: pageForReq(app, r),
|
||||||
|
Flashes: f,
|
||||||
|
EmailEnabled: app.cfg.Email.Enabled(),
|
||||||
|
CSRFField: csrf.TemplateField(r),
|
||||||
|
Token: token,
|
||||||
|
IsResetting: resetting,
|
||||||
|
IsSent: r.FormValue("sent") == "1",
|
||||||
|
}
|
||||||
|
err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to render password reset page: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func doAutomatedPasswordChange(app *App, userID int64, newPass string) error {
|
||||||
|
// Do password reset
|
||||||
|
hashedPass, err := auth.HashPass([]byte(newPass))
|
||||||
|
if err != nil {
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do update
|
||||||
|
err = app.db.ChangePassphrase(userID, true, "", hashedPass)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
returnLoc := impart.HTTPError{http.StatusFound, "/reset"}
|
||||||
|
|
||||||
|
if !app.cfg.Email.Enabled() {
|
||||||
|
// Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
ip := spam.GetIP(r)
|
||||||
|
alias := r.FormValue("alias")
|
||||||
|
|
||||||
|
u, err := app.db.GetUserForAuth(alias)
|
||||||
|
if err != nil {
|
||||||
|
if strings.IndexAny(alias, "@") > 0 {
|
||||||
|
addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
addSessionFlash(app, w, r, ErrUserNotFound.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
if u.IsAdmin() {
|
||||||
|
// Prevent any reset emails on admin accounts
|
||||||
|
log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
if u.Email.String == "" {
|
||||||
|
err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."}
|
||||||
|
addSessionFlash(app, w, r, err.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet {
|
||||||
|
err = loginViaEmail(app, u.Username, "/me/settings")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := app.db.CreatePasswordResetToken(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error resetting password: %s", err)
|
||||||
|
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
err = emailPasswordReset(app, u.EmailClear(app.keys), token)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error emailing password reset: %s", err)
|
||||||
|
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil)
|
||||||
|
returnLoc.Message += "?sent=1"
|
||||||
|
return returnLoc
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailPasswordReset(app *App, toEmail, token string) error {
|
||||||
|
// Send email
|
||||||
|
mlr, err := mailer.New(app.cfg.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email."
|
||||||
|
|
||||||
|
plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)
|
||||||
|
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.AddTag("Password Reset")
|
||||||
|
m.SetHTML(fmt.Sprintf(`<html>
|
||||||
|
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||||
|
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||||
|
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||||
|
<p>We received a request to reset your password on %s. Please click the following link to continue:</p>
|
||||||
|
<p style="font-size:1.2em;margin-bottom:1.5em;"><a href="%s/reset?t=%s">Reset your password</a></p>
|
||||||
|
<p style="font-size: 0.86em;margin:1em auto">%s</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara))
|
||||||
|
return mlr.Send(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginViaEmail(app *App, alias, redirectTo string) error {
|
||||||
|
if !app.cfg.Email.Enabled() {
|
||||||
|
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure user has added an email
|
||||||
|
// TODO: create a new func to just get user's email; "ForAuth" doesn't match here
|
||||||
|
u, _ := app.db.GetUserForAuth(alias)
|
||||||
|
if u == nil {
|
||||||
|
if strings.IndexAny(alias, "@") > 0 {
|
||||||
|
return ErrUserNotFoundEmail
|
||||||
|
}
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
if u.Email.String == "" {
|
||||||
|
return impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Log in with password, instead."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate one-time login token
|
||||||
|
t, err := app.db.GetTemporaryOneTimeAccessToken(u.ID, 60*15, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to generate token for email login: %s", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, "Unable to generate token."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
mlr, err := mailer.New(app.cfg.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
toEmail := u.EmailClear(app.keys)
|
||||||
|
footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email."
|
||||||
|
|
||||||
|
plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara)
|
||||||
|
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.AddTag("Email Login")
|
||||||
|
|
||||||
|
m.SetHTML(fmt.Sprintf(`<html>
|
||||||
|
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||||
|
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||||
|
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||||
|
<p style="font-size:1.2em;margin-bottom:1.5em;text-align:center"><a href="%s/login?to=%s&with=%s">Log in to %s here</a>.</p>
|
||||||
|
<p style="font-size: 0.86em;color:#666;text-align:center;max-width:35em;margin:1em auto">%s</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara))
|
||||||
|
return mlr.Send(m)
|
||||||
|
}
|
||||||
|
|
||||||
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
|
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
|
||||||
session, err := app.sessionStore.Get(r, "t")
|
session, err := app.sessionStore.Get(r, "t")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -100,7 +99,7 @@ func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
tempFile, err := ioutil.TempFile("", "post-upload-*.txt")
|
tempFile, err := os.CreateTemp("", "post-upload-*.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||||
log.Error("import file: create temp file %s: %v", formFile.Filename, err)
|
log.Error("import file: create temp file %s: %v", formFile.Filename, err)
|
||||||
|
|
334
activitypub.go
334
activitypub.go
|
@ -17,22 +17,26 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/writeas/activity/streams"
|
"github.com/writeas/activity/streams"
|
||||||
|
"github.com/writeas/activityserve"
|
||||||
"github.com/writeas/httpsig"
|
"github.com/writeas/httpsig"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/web-core/activitypub"
|
"github.com/writeas/web-core/activitypub"
|
||||||
"github.com/writeas/web-core/activitystreams"
|
"github.com/writeas/web-core/activitystreams"
|
||||||
"github.com/writeas/web-core/id"
|
"github.com/writeas/web-core/id"
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
|
"github.com/writeas/web-core/silobridge"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -42,6 +46,11 @@ const (
|
||||||
apCacheTime = time.Minute
|
apCacheTime = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
apCollectionPostIRIRegex = regexp.MustCompile("/api/collections/([a-z0-9\\-]+)/posts/([a-z0-9\\-]+)$")
|
||||||
|
apDraftPostIRIRegex = regexp.MustCompile("/api/posts/([a-z0-9\\-]+)$")
|
||||||
|
)
|
||||||
|
|
||||||
var instanceColl *Collection
|
var instanceColl *Collection
|
||||||
|
|
||||||
func initActivityPub(app *App) {
|
func initActivityPub(app *App) {
|
||||||
|
@ -60,7 +69,22 @@ type RemoteUser struct {
|
||||||
ActorID string
|
ActorID string
|
||||||
Inbox string
|
Inbox string
|
||||||
SharedInbox string
|
SharedInbox string
|
||||||
|
URL string
|
||||||
Handle string
|
Handle string
|
||||||
|
Created time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ru *RemoteUser) CreatedFriendly() string {
|
||||||
|
return ru.Created.Format("January 2, 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ru *RemoteUser) EstimatedHandle() string {
|
||||||
|
if ru.Handle != "" {
|
||||||
|
return ru.Handle
|
||||||
|
}
|
||||||
|
username := filepath.Base(ru.ActorID)
|
||||||
|
host, _ := url.Parse(ru.ActorID)
|
||||||
|
return username + "@" + host.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
||||||
|
@ -177,7 +201,7 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
||||||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
|
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
|
||||||
ocp.OrderedItems = []interface{}{}
|
ocp.OrderedItems = []interface{}{}
|
||||||
|
|
||||||
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
|
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false, "")
|
||||||
for _, pp := range *posts {
|
for _, pp := range *posts {
|
||||||
pp.Collection = res
|
pp.Collection = res
|
||||||
o := pp.ActivityObject(app)
|
o := pp.ActivityObject(app)
|
||||||
|
@ -333,11 +357,60 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
a := streams.NewAccept()
|
a := streams.NewAccept()
|
||||||
p := c.PersonObject()
|
p := c.PersonObject()
|
||||||
var to *url.URL
|
var to *url.URL
|
||||||
var isFollow, isUnfollow bool
|
var isFollow, isUnfollow, isLike, isUnlike bool
|
||||||
|
var likePostID, unlikePostID string
|
||||||
fullActor := &activitystreams.Person{}
|
fullActor := &activitystreams.Person{}
|
||||||
var remoteUser *RemoteUser
|
var remoteUser *RemoteUser
|
||||||
|
|
||||||
res := &streams.Resolver{
|
res := &streams.Resolver{
|
||||||
|
LikeCallback: func(l *streams.Like) error {
|
||||||
|
isLike = true
|
||||||
|
|
||||||
|
// 1) Use the Like concrete type here
|
||||||
|
// 2) Errors are propagated to res.Deserialize call below
|
||||||
|
m["@context"] = []string{activitystreams.Namespace}
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
if debugging {
|
||||||
|
log.Info("Like: %s", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, likeID := l.GetId()
|
||||||
|
if likeID == nil {
|
||||||
|
log.Error("Didn't resolve Like ID")
|
||||||
|
}
|
||||||
|
if p := l.HasObject(0); p == streams.NoPresence {
|
||||||
|
return fmt.Errorf("no object for Like activity at index 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := l.Raw().GetObjectIRI(0)
|
||||||
|
/*
|
||||||
|
// TODO: handle this more robustly
|
||||||
|
l.ResolveObject(&streams.Resolver{
|
||||||
|
LinkCallback: func(link *streams.Link) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}, 0)
|
||||||
|
*/
|
||||||
|
|
||||||
|
if obj == nil {
|
||||||
|
return fmt.Errorf("didn't get ObjectIRI to Like")
|
||||||
|
}
|
||||||
|
likePostID, err = parsePostIDFromURL(app, obj)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, get actor information
|
||||||
|
_, from := l.GetActor(0)
|
||||||
|
if from == nil {
|
||||||
|
return fmt.Errorf("No valid actor string")
|
||||||
|
}
|
||||||
|
fullActor, remoteUser, err = getActor(app, from.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
FollowCallback: func(f *streams.Follow) error {
|
FollowCallback: func(f *streams.Follow) error {
|
||||||
isFollow = true
|
isFollow = true
|
||||||
|
|
||||||
|
@ -363,6 +436,17 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
a.AppendObject(f.Raw())
|
a.AppendObject(f.Raw())
|
||||||
_, to = f.GetActor(0)
|
_, to = f.GetActor(0)
|
||||||
obj := f.Raw().GetObjectIRI(0)
|
obj := f.Raw().GetObjectIRI(0)
|
||||||
|
if obj == nil {
|
||||||
|
if debugging {
|
||||||
|
log.Error("GetObjectIRI on Follow for actor is empty; trying object")
|
||||||
|
}
|
||||||
|
ao := f.Raw().GetObject(0)
|
||||||
|
if ao == nil {
|
||||||
|
log.Error("Fell back to GetObject and none parsed, so no actor ID! Follow request probably FAILED!")
|
||||||
|
} else {
|
||||||
|
obj = ao.GetId()
|
||||||
|
}
|
||||||
|
}
|
||||||
a.AppendActor(obj)
|
a.AppendActor(obj)
|
||||||
|
|
||||||
// First get actor information
|
// First get actor information
|
||||||
|
@ -376,8 +460,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
return impart.RenderActivityJSON(w, m, http.StatusOK)
|
return impart.RenderActivityJSON(w, m, http.StatusOK)
|
||||||
},
|
},
|
||||||
UndoCallback: func(u *streams.Undo) error {
|
UndoCallback: func(u *streams.Undo) error {
|
||||||
isUnfollow = true
|
|
||||||
|
|
||||||
m["@context"] = []string{activitystreams.Namespace}
|
m["@context"] = []string{activitystreams.Namespace}
|
||||||
b, _ := json.Marshal(m)
|
b, _ := json.Marshal(m)
|
||||||
if debugging {
|
if debugging {
|
||||||
|
@ -385,6 +467,37 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
a.AppendObject(u.Raw())
|
a.AppendObject(u.Raw())
|
||||||
|
|
||||||
|
// Check type -- we handle Undo:Like and Undo:Follow
|
||||||
|
_, err := u.ResolveObject(&streams.Resolver{
|
||||||
|
LikeCallback: func(like *streams.Like) error {
|
||||||
|
isUnlike = true
|
||||||
|
|
||||||
|
_, from := like.GetActor(0)
|
||||||
|
obj := like.Raw().GetObjectIRI(0)
|
||||||
|
if obj == nil {
|
||||||
|
return fmt.Errorf("didn't get ObjectIRI for Undo Like")
|
||||||
|
}
|
||||||
|
unlikePostID, err = parsePostIDFromURL(app, obj)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fullActor, remoteUser, err = getActor(app, from.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// TODO: add FollowCallback for more robust handling
|
||||||
|
}, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if isUnlike {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnfollow = true
|
||||||
_, to = u.GetActor(0)
|
_, to = u.GetActor(0)
|
||||||
// TODO: get actor from object.object, not object
|
// TODO: get actor from object.object, not object
|
||||||
obj := u.Raw().GetObjectIRI(0)
|
obj := u.Raw().GetObjectIRI(0)
|
||||||
|
@ -417,6 +530,81 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle synchronous activities
|
||||||
|
if isLike {
|
||||||
|
t, err := app.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to start transaction: %v", err)
|
||||||
|
return fmt.Errorf("unable to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteUserID int64
|
||||||
|
if remoteUser != nil {
|
||||||
|
remoteUserID = remoteUser.ID
|
||||||
|
} else {
|
||||||
|
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add like
|
||||||
|
_, err = t.Exec("INSERT INTO remote_likes (post_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", likePostID, remoteUserID)
|
||||||
|
if err != nil {
|
||||||
|
if !app.db.isDuplicateKeyErr(err) {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Couldn't add like in DB: %v\n", err)
|
||||||
|
return fmt.Errorf("Couldn't add like in DB: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Couldn't add like in DB: %v\n", err)
|
||||||
|
return fmt.Errorf("Couldn't add like in DB: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Rolling back after Commit(): %v\n", err)
|
||||||
|
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if debugging {
|
||||||
|
log.Info("Successfully liked post %s by remote user %s", likePostID, remoteUser.URL)
|
||||||
|
}
|
||||||
|
return impart.RenderActivityJSON(w, "", http.StatusOK)
|
||||||
|
} else if isUnlike {
|
||||||
|
t, err := app.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to start transaction: %v", err)
|
||||||
|
return fmt.Errorf("unable to start transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteUserID int64
|
||||||
|
if remoteUser != nil {
|
||||||
|
remoteUserID = remoteUser.ID
|
||||||
|
} else {
|
||||||
|
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove like
|
||||||
|
_, err = t.Exec("DELETE FROM remote_likes WHERE post_id = ? AND remote_user_id = ?", unlikePostID, remoteUserID)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Couldn't delete Like from DB: %v\n", err)
|
||||||
|
return fmt.Errorf("Couldn't delete Like from DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Rolling back after Commit(): %v\n", err)
|
||||||
|
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if debugging {
|
||||||
|
log.Info("Successfully un-liked post %s by remote user %s", unlikePostID, remoteUser.URL)
|
||||||
|
}
|
||||||
|
return impart.RenderActivityJSON(w, "", http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if to == nil {
|
if to == nil {
|
||||||
if debugging {
|
if debugging {
|
||||||
|
@ -451,8 +639,9 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
if remoteUser != nil {
|
if remoteUser != nil {
|
||||||
followerID = remoteUser.ID
|
followerID = remoteUser.ID
|
||||||
} else {
|
} else {
|
||||||
|
// TODO: use apAddRemoteUser() here, instead!
|
||||||
// Add follower locally, since it wasn't found before
|
// Add follower locally, since it wasn't found before
|
||||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox)
|
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// if duplicate key, res will be nil and panic on
|
// if duplicate key, res will be nil and panic on
|
||||||
// res.LastInsertId below
|
// res.LastInsertId below
|
||||||
|
@ -549,7 +738,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -601,7 +790,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -644,10 +833,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
||||||
|
|
||||||
for si, instFolls := range inboxes {
|
for si, instFolls := range inboxes {
|
||||||
na.CC = []string{}
|
na.CC = []string{}
|
||||||
for _, f := range instFolls {
|
na.CC = append(na.CC, instFolls...)
|
||||||
na.CC = append(na.CC, f)
|
|
||||||
}
|
|
||||||
|
|
||||||
da := activitystreams.NewDeleteActivity(na)
|
da := activitystreams.NewDeleteActivity(na)
|
||||||
// Make the ID unique to ensure it works in Pleroma
|
// Make the ID unique to ensure it works in Pleroma
|
||||||
// See: https://git.pleroma.social/pleroma/pleroma/issues/1481
|
// See: https://git.pleroma.social/pleroma/pleroma/issues/1481
|
||||||
|
@ -713,12 +899,11 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||||
// add all followers from that instance
|
// add all followers from that instance
|
||||||
// to the CC field
|
// to the CC field
|
||||||
na.CC = []string{}
|
na.CC = []string{}
|
||||||
for _, f := range instFolls {
|
na.CC = append(na.CC, instFolls...)
|
||||||
na.CC = append(na.CC, f)
|
|
||||||
}
|
|
||||||
// create a new "Create" activity
|
// create a new "Create" activity
|
||||||
// with our article as object
|
// with our article as object
|
||||||
if isUpdate {
|
if isUpdate {
|
||||||
|
na.Updated = &p.Updated
|
||||||
activity = activitystreams.NewUpdateActivity(na)
|
activity = activitystreams.NewUpdateActivity(na)
|
||||||
} else {
|
} else {
|
||||||
activity = activitystreams.NewCreateActivity(na)
|
activity = activitystreams.NewCreateActivity(na)
|
||||||
|
@ -764,8 +949,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||||
|
|
||||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||||
u := RemoteUser{ActorID: actorID}
|
u := RemoteUser{ActorID: actorID}
|
||||||
var handle sql.NullString
|
var urlVal, handle sql.NullString
|
||||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &handle)
|
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, url, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &urlVal, &handle)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
||||||
|
@ -774,6 +959,7 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u.URL = urlVal.String
|
||||||
u.Handle = handle.String
|
u.Handle = handle.String
|
||||||
|
|
||||||
return &u, nil
|
return &u, nil
|
||||||
|
@ -783,7 +969,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||||
// from the @user@server.tld handle
|
// from the @user@server.tld handle
|
||||||
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
||||||
u := RemoteUser{Handle: handle}
|
u := RemoteUser{Handle: handle}
|
||||||
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox)
|
var urlVal sql.NullString
|
||||||
|
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox, url FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox, &urlVal)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return nil, ErrRemoteUserNotFound
|
return nil, ErrRemoteUserNotFound
|
||||||
|
@ -791,6 +978,7 @@ func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
||||||
log.Error("Couldn't get remote user %s: %v", handle, err)
|
log.Error("Couldn't get remote user %s: %v", handle, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
u.URL = urlVal.String
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -805,13 +993,28 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
||||||
log.Info("Not found; fetching actor %s remotely", actorIRI)
|
log.Info("Not found; fetching actor %s remotely", actorIRI)
|
||||||
actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI)
|
actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to get actor! %v", err)
|
log.Error("Unable to get base actor! %v", err)
|
||||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actor."}
|
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actor."}
|
||||||
}
|
}
|
||||||
if err := unmarshalActor(actorResp, actor); err != nil {
|
if err := unmarshalActor(actorResp, actor); err != nil {
|
||||||
log.Error("Unable to unmarshal actor! %v", err)
|
log.Error("Unable to unmarshal base actor! %v", err)
|
||||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actor."}
|
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actor."}
|
||||||
}
|
}
|
||||||
|
baseActor := &activitystreams.Person{}
|
||||||
|
if err := unmarshalActor(actorResp, baseActor); err != nil {
|
||||||
|
log.Error("Unable to unmarshal actual actor! %v", err)
|
||||||
|
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."}
|
||||||
|
}
|
||||||
|
// Fetch the actual actor using the owner field from the publicKey object
|
||||||
|
actualActorResp, err := resolveIRI(app.cfg.App.Host, baseActor.PublicKey.Owner)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to get actual actor! %v", err)
|
||||||
|
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actual actor."}
|
||||||
|
}
|
||||||
|
if err := unmarshalActor(actualActorResp, actor); err != nil {
|
||||||
|
log.Error("Unable to unmarshal actual actor! %v", err)
|
||||||
|
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
@ -824,6 +1027,69 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
||||||
return actor, remoteUser, nil
|
return actor, remoteUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetProfileURLFromHandle(app *App, handle string) (string, error) {
|
||||||
|
handle = strings.TrimLeft(handle, "@")
|
||||||
|
actorIRI := ""
|
||||||
|
parts := strings.Split(handle, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid handle format")
|
||||||
|
}
|
||||||
|
domain := parts[1]
|
||||||
|
|
||||||
|
// Check non-AP instances
|
||||||
|
if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" {
|
||||||
|
return siloProfileURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteUser, err := getRemoteUserFromHandle(app, handle)
|
||||||
|
if err != nil {
|
||||||
|
// can't find using handle in the table but the table may already have this user without
|
||||||
|
// handle from a previous version
|
||||||
|
// TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all
|
||||||
|
actorIRI = RemoteLookup(handle)
|
||||||
|
_, errRemoteUser := getRemoteUser(app, actorIRI)
|
||||||
|
// if it exists then we need to update the handle
|
||||||
|
if errRemoteUser == nil {
|
||||||
|
_, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// this probably means we don't have the user in the table so let's try to insert it
|
||||||
|
// here we need to ask the server for the inboxes
|
||||||
|
remoteActor, err := activityserve.NewRemoteActor(actorIRI)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't fetch remote actor: %v", err)
|
||||||
|
}
|
||||||
|
if debugging {
|
||||||
|
log.Info("Got remote actor: %s %s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||||
|
}
|
||||||
|
_, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url, handle) VALUES(?, ?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't insert remote user: %v", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
actorIRI = remoteActor.URL()
|
||||||
|
}
|
||||||
|
} else if remoteUser.URL == "" {
|
||||||
|
log.Info("Remote user %s URL empty, fetching", remoteUser.ActorID)
|
||||||
|
newRemoteActor, err := activityserve.NewRemoteActor(remoteUser.ActorID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't fetch remote actor: %v", err)
|
||||||
|
} else {
|
||||||
|
_, err := app.db.Exec("UPDATE remoteusers SET url = ? WHERE actor_id = ?", newRemoteActor.URL(), remoteUser.ActorID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||||
|
} else {
|
||||||
|
actorIRI = newRemoteActor.URL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actorIRI = remoteUser.URL
|
||||||
|
}
|
||||||
|
return actorIRI, nil
|
||||||
|
}
|
||||||
|
|
||||||
// unmarshal actor normalizes the actor response to conform to
|
// unmarshal actor normalizes the actor response to conform to
|
||||||
// the type Person from github.com/writeas/web-core/activitysteams
|
// the type Person from github.com/writeas/web-core/activitysteams
|
||||||
//
|
//
|
||||||
|
@ -869,6 +1135,34 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePostIDFromURL(app *App, u *url.URL) (string, error) {
|
||||||
|
// Get post ID from URL
|
||||||
|
var collAlias, slug, postID string
|
||||||
|
if m := apCollectionPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 3 {
|
||||||
|
collAlias = m[1]
|
||||||
|
slug = m[2]
|
||||||
|
} else if m = apDraftPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 2 {
|
||||||
|
postID = m[1]
|
||||||
|
} else {
|
||||||
|
return "", fmt.Errorf("unable to match objectIRI: %s", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get postID if all we have is collection and slug
|
||||||
|
if collAlias != "" && slug != "" {
|
||||||
|
c, err := app.db.GetCollection(collAlias)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
p, err := app.db.GetPost(slug, c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
postID = p.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return postID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
||||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||||
}
|
}
|
||||||
|
|
30
admin.go
30
admin.go
|
@ -13,6 +13,7 @@ package writefreely
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -102,13 +103,16 @@ func NewAdminPage(app *App) *AdminPage {
|
||||||
return ap
|
return ap
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c instanceContent) UpdatedFriendly() string {
|
func (c instanceContent) UpdatedFriendly() template.HTML {
|
||||||
/*
|
/*
|
||||||
// TODO: accept a locale in this method and use that for the format
|
// TODO: accept a locale in this method and use that for the format
|
||||||
var loc monday.Locale = monday.LocaleEnUS
|
var loc monday.Locale = monday.LocaleEnUS
|
||||||
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
|
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
|
||||||
*/
|
*/
|
||||||
return c.Updated.Format("January 2, 2006, 3:04 PM")
|
if c.Updated.IsZero() {
|
||||||
|
return "<em>Never</em>"
|
||||||
|
}
|
||||||
|
return template.HTML(c.Updated.Format("January 2, 2006, 3:04 PM"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
@ -204,7 +208,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
||||||
|
|
||||||
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
|
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
|
||||||
p.TotalUsers = app.db.GetAllUsersCount()
|
p.TotalUsers = app.db.GetAllUsersCount()
|
||||||
ttlPages := p.TotalUsers / adminUsersPerPage
|
ttlPages := (p.TotalUsers - 1) / adminUsersPerPage + 1
|
||||||
p.TotalPages = []int{}
|
p.TotalPages = []int{}
|
||||||
for i := 1; i <= int(ttlPages); i++ {
|
for i := 1; i <= int(ttlPages); i++ {
|
||||||
p.TotalPages = append(p.TotalPages, i)
|
p.TotalPages = append(p.TotalPages, i)
|
||||||
|
@ -426,9 +430,9 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add in default pages
|
// Add in default pages
|
||||||
var hasAbout, hasPrivacy bool
|
var hasAbout, hasContact, hasPrivacy bool
|
||||||
for i, c := range p.Pages {
|
for i, c := range p.Pages {
|
||||||
if hasAbout && hasPrivacy {
|
if hasAbout && hasContact && hasPrivacy {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if c.ID == "about" {
|
if c.ID == "about" {
|
||||||
|
@ -436,6 +440,11 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
||||||
if !c.Title.Valid {
|
if !c.Title.Valid {
|
||||||
p.Pages[i].Title = defaultAboutTitle(app.cfg)
|
p.Pages[i].Title = defaultAboutTitle(app.cfg)
|
||||||
}
|
}
|
||||||
|
} else if c.ID == "contact" {
|
||||||
|
hasContact = true
|
||||||
|
if !c.Title.Valid {
|
||||||
|
p.Pages[i].Title = defaultContactTitle()
|
||||||
|
}
|
||||||
} else if c.ID == "privacy" {
|
} else if c.ID == "privacy" {
|
||||||
hasPrivacy = true
|
hasPrivacy = true
|
||||||
if !c.Title.Valid {
|
if !c.Title.Valid {
|
||||||
|
@ -451,6 +460,13 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
||||||
Updated: defaultPageUpdatedTime,
|
Updated: defaultPageUpdatedTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if !hasContact {
|
||||||
|
p.Pages = append(p.Pages, &instanceContent{
|
||||||
|
ID: "contact",
|
||||||
|
Title: defaultContactTitle(),
|
||||||
|
Content: defaultContactPage(app),
|
||||||
|
})
|
||||||
|
}
|
||||||
if !hasPrivacy {
|
if !hasPrivacy {
|
||||||
p.Pages = append(p.Pages, &instanceContent{
|
p.Pages = append(p.Pages, &instanceContent{
|
||||||
ID: "privacy",
|
ID: "privacy",
|
||||||
|
@ -489,6 +505,8 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
||||||
// Get pre-defined pages, or select slug
|
// Get pre-defined pages, or select slug
|
||||||
if slug == "about" {
|
if slug == "about" {
|
||||||
p.Content, err = getAboutPage(app)
|
p.Content, err = getAboutPage(app)
|
||||||
|
} else if slug == "contact" {
|
||||||
|
p.Content, err = getContactPage(app)
|
||||||
} else if slug == "privacy" {
|
} else if slug == "privacy" {
|
||||||
p.Content, err = getPrivacyPage(app)
|
p.Content, err = getPrivacyPage(app)
|
||||||
} else if slug == "landing" {
|
} else if slug == "landing" {
|
||||||
|
@ -523,7 +541,7 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req
|
||||||
id := vars["page"]
|
id := vars["page"]
|
||||||
|
|
||||||
// Validate
|
// Validate
|
||||||
if id != "about" && id != "privacy" && id != "landing" && id != "reader" {
|
if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" {
|
||||||
return impart.HTTPError{http.StatusNotFound, "No such page."}
|
return impart.HTTPError{http.StatusNotFound, "No such page."}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
120
app.go
120
app.go
|
@ -13,9 +13,10 @@ package writefreely
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/ioutil"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
@ -35,18 +36,20 @@ import (
|
||||||
"github.com/writeas/web-core/auth"
|
"github.com/writeas/web-core/auth"
|
||||||
"github.com/writeas/web-core/converter"
|
"github.com/writeas/web-core/converter"
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
|
||||||
"github.com/writefreely/writefreely/author"
|
"github.com/writefreely/writefreely/author"
|
||||||
"github.com/writefreely/writefreely/config"
|
"github.com/writefreely/writefreely/config"
|
||||||
"github.com/writefreely/writefreely/key"
|
"github.com/writefreely/writefreely/key"
|
||||||
"github.com/writefreely/writefreely/migrations"
|
"github.com/writefreely/writefreely/migrations"
|
||||||
"github.com/writefreely/writefreely/page"
|
"github.com/writefreely/writefreely/page"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
staticDir = "static"
|
staticDir = "static"
|
||||||
assumedTitleLen = 80
|
assumedTitleLen = 80
|
||||||
postsPerPage = 10
|
postsPerPage = 10
|
||||||
|
postsPerArchPage = 40
|
||||||
|
|
||||||
serverSoftware = "WriteFreely"
|
serverSoftware = "WriteFreely"
|
||||||
softwareURL = "https://writefreely.org"
|
softwareURL = "https://writefreely.org"
|
||||||
|
@ -56,7 +59,7 @@ var (
|
||||||
debugging bool
|
debugging bool
|
||||||
|
|
||||||
// Software version can be set from git env using -ldflags
|
// Software version can be set from git env using -ldflags
|
||||||
softwareVer = "0.13.2"
|
softwareVer = "0.15.1"
|
||||||
|
|
||||||
// DEPRECATED VARS
|
// DEPRECATED VARS
|
||||||
isSingleUser bool
|
isSingleUser bool
|
||||||
|
@ -174,7 +177,7 @@ func (app *App) LoadKeys() error {
|
||||||
executable = filepath.Base(executable)
|
executable = filepath.Base(executable)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
|
app.keys.EmailKey, err = os.ReadFile(emailKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -182,7 +185,7 @@ func (app *App) LoadKeys() error {
|
||||||
if debugging {
|
if debugging {
|
||||||
log.Info(" %s", cookieAuthKeyPath)
|
log.Info(" %s", cookieAuthKeyPath)
|
||||||
}
|
}
|
||||||
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
|
app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -190,7 +193,7 @@ func (app *App) LoadKeys() error {
|
||||||
if debugging {
|
if debugging {
|
||||||
log.Info(" %s", cookieKeyPath)
|
log.Info(" %s", cookieKeyPath)
|
||||||
}
|
}
|
||||||
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
|
app.keys.CookieKey, err = os.ReadFile(cookieKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -198,7 +201,7 @@ func (app *App) LoadKeys() error {
|
||||||
if debugging {
|
if debugging {
|
||||||
log.Info(" %s", csrfKeyPath)
|
log.Info(" %s", csrfKeyPath)
|
||||||
}
|
}
|
||||||
app.keys.CSRFKey, err = ioutil.ReadFile(csrfKeyPath)
|
app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
log.Error(`Missing key: %s.
|
log.Error(`Missing key: %s.
|
||||||
|
@ -315,7 +318,7 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
||||||
}{
|
}{
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
|
if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" {
|
||||||
var c *instanceContent
|
var c *instanceContent
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
@ -326,6 +329,12 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
||||||
p.AboutStats = &InstanceStats{}
|
p.AboutStats = &InstanceStats{}
|
||||||
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
|
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
|
||||||
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
|
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
|
||||||
|
} else if r.URL.Path == "/contact" {
|
||||||
|
c, err = getContactPage(app)
|
||||||
|
if c.Updated.IsZero() {
|
||||||
|
// Page was never set up, so return 404
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
c, err = getPrivacyPage(app)
|
c, err = getPrivacyPage(app)
|
||||||
}
|
}
|
||||||
|
@ -356,6 +365,11 @@ func pageForReq(app *App, r *http.Request) page.StaticPage {
|
||||||
Version: "v" + softwareVer,
|
Version: "v" + softwareVer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use custom style, if file exists
|
||||||
|
if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil {
|
||||||
|
p.CustomCSS = true
|
||||||
|
}
|
||||||
|
|
||||||
// Add user information, if given
|
// Add user information, if given
|
||||||
var u *User
|
var u *User
|
||||||
accessToken := r.FormValue("t")
|
accessToken := r.FormValue("t")
|
||||||
|
@ -415,6 +429,13 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
||||||
|
|
||||||
initActivityPub(apper.App())
|
initActivityPub(apper.App())
|
||||||
|
|
||||||
|
if apper.App().cfg.Email.Enabled() {
|
||||||
|
log.Info("Starting publish jobs queue...")
|
||||||
|
go startPublishJobsQueue(apper.App())
|
||||||
|
} else {
|
||||||
|
log.Error("[FAILED] Starting publish jobs queue: no email provider is configured.")
|
||||||
|
}
|
||||||
|
|
||||||
// Handle local timeline, if enabled
|
// Handle local timeline, if enabled
|
||||||
if apper.App().cfg.App.LocalTimeline {
|
if apper.App().cfg.App.LocalTimeline {
|
||||||
log.Info("Initializing local timeline...")
|
log.Info("Initializing local timeline...")
|
||||||
|
@ -503,9 +524,41 @@ requests. We recommend supplying a valid host name.`)
|
||||||
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
|
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
|
network := "tcp"
|
||||||
|
protocol := "http"
|
||||||
|
if strings.HasPrefix(bindAddress, "/") {
|
||||||
|
network = "unix"
|
||||||
|
protocol = "http+unix"
|
||||||
|
|
||||||
|
// old sockets will remain after server closes;
|
||||||
|
// we need to delete them in order to open new ones
|
||||||
|
err = os.Remove(bindAddress)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Error("%s already exists but could not be removed: %v", bindAddress, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Serving on %s://%s", protocol, bindAddress)
|
||||||
log.Info("---")
|
log.Info("---")
|
||||||
err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
|
listener, err := net.Listen(network, bindAddress)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not bind to address: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if network == "unix" {
|
||||||
|
err = os.Chmod(bindAddress, 0o666)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not update socket permissions: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer listener.Close()
|
||||||
|
err = http.Serve(listener, r)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to start: %v", err)
|
log.Error("Unable to start: %v", err)
|
||||||
|
@ -529,8 +582,8 @@ func (app *App) InitDecoder() {
|
||||||
// tests the connection.
|
// tests the connection.
|
||||||
func ConnectToDatabase(app *App) error {
|
func ConnectToDatabase(app *App) error {
|
||||||
// Check database configuration
|
// Check database configuration
|
||||||
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
|
if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" {
|
||||||
return fmt.Errorf("Database user or password not set.")
|
return fmt.Errorf("Database user not set.")
|
||||||
}
|
}
|
||||||
if app.cfg.Database.Host == "" {
|
if app.cfg.Database.Host == "" {
|
||||||
app.cfg.Database.Host = "localhost"
|
app.cfg.Database.Host = "localhost"
|
||||||
|
@ -812,6 +865,16 @@ func connectToDatabase(app *App) {
|
||||||
func shutdown(app *App) {
|
func shutdown(app *App) {
|
||||||
log.Info("Closing database connection...")
|
log.Info("Closing database connection...")
|
||||||
app.db.Close()
|
app.db.Close()
|
||||||
|
if strings.HasPrefix(app.cfg.Server.Bind, "/") {
|
||||||
|
// Clean up socket
|
||||||
|
log.Info("Removing socket file...")
|
||||||
|
err := os.Remove(app.cfg.Server.Bind)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to remove socket: %s", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
log.Info("Success.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser creates a new admin or normal user from the given credentials.
|
// CreateUser creates a new admin or normal user from the given credentials.
|
||||||
|
@ -826,12 +889,12 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
||||||
if isAdmin {
|
if isAdmin {
|
||||||
// Abort if trying to create admin user, but one already exists
|
// Abort if trying to create admin user, but one already exists
|
||||||
if firstUser != nil {
|
if firstUser != nil {
|
||||||
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
|
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely user create [USER]:[PASSWORD]", firstUser.Username)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Abort if trying to create regular user, but no admin exists yet
|
// Abort if trying to create regular user, but no admin exists yet
|
||||||
if firstUser == nil {
|
if firstUser == nil {
|
||||||
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
|
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely user create --admin [USER]:[PASSWORD]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -874,15 +937,18 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminInitDatabase(app *App) error {
|
//go:embed schema.sql
|
||||||
schemaFileName := "schema.sql"
|
var schemaSql string
|
||||||
if app.cfg.Database.Type == driverSQLite {
|
|
||||||
schemaFileName = "sqlite.sql"
|
|
||||||
}
|
|
||||||
|
|
||||||
schema, err := Asset(schemaFileName)
|
//go:embed sqlite.sql
|
||||||
if err != nil {
|
var sqliteSql string
|
||||||
return fmt.Errorf("Unable to load schema file: %v", err)
|
|
||||||
|
func adminInitDatabase(app *App) error {
|
||||||
|
var schema string
|
||||||
|
if app.cfg.Database.Type == driverSQLite {
|
||||||
|
schema = sqliteSql
|
||||||
|
} else {
|
||||||
|
schema = schemaSql
|
||||||
}
|
}
|
||||||
|
|
||||||
tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
|
tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
|
||||||
|
@ -898,7 +964,7 @@ func adminInitDatabase(app *App) error {
|
||||||
} else {
|
} else {
|
||||||
log.Info("Creating table ??? (Weird query) No match in: %v", parts)
|
log.Info("Creating table ??? (Weird query) No match in: %v", parts)
|
||||||
}
|
}
|
||||||
_, err = app.db.Exec(q)
|
_, err := app.db.Exec(q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("%s", err)
|
log.Error("%s", err)
|
||||||
} else {
|
} else {
|
||||||
|
@ -908,7 +974,7 @@ func adminInitDatabase(app *App) error {
|
||||||
|
|
||||||
// Set up migrations table
|
// Set up migrations table
|
||||||
log.Info("Initializing appmigrations table...")
|
log.Info("Initializing appmigrations table...")
|
||||||
err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Unable to set initial migrations: %v", err)
|
return fmt.Errorf("Unable to set initial migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
package author
|
package author
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
"github.com/writefreely/writefreely/config"
|
"github.com/writefreely/writefreely/config"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -113,10 +114,17 @@ func IsValidUsername(cfg *config.Config, username string) bool {
|
||||||
// Username is invalid if page with the same name exists. So traverse
|
// Username is invalid if page with the same name exists. So traverse
|
||||||
// available pages, adding them to reservedUsernames map that'll be checked
|
// available pages, adding them to reservedUsernames map that'll be checked
|
||||||
// later.
|
// later.
|
||||||
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
|
err := filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
reservedUsernames[i.Name()] = true
|
reservedUsernames[i.Name()] = true
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[IMPORTANT WARNING]: Could not determine IsValidUsername! %s", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Username is invalid if it is reserved!
|
// Username is invalid if it is reserved!
|
||||||
if _, reserved := reservedUsernames[username]; reserved {
|
if _, reserved := reservedUsernames[username]; reserved {
|
||||||
|
|
106
bindata-lib.go
106
bindata-lib.go
|
@ -1,106 +0,0 @@
|
||||||
// +build wflib
|
|
||||||
|
|
||||||
package writefreely
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func bindata_read(data []byte, name string) ([]byte, error) {
|
|
||||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
_, err = io.Copy(&buf, gz)
|
|
||||||
gz.Close()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _schema_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xd4\x59\x5f\x6f\xa3\x38\x10\x7f\xef\xa7\xf0\xdb\xa6\x52\x23\x6d\x7a\xdd\xaa\xba\xd3\x3e\x64\x53\x76\x2f\xba\x94\xee\x25\x44\xba\x7d\x02\x03\x93\xd4\xaa\xb1\x91\x6d\x92\xe6\xdb\x9f\x8c\x49\x08\x86\x24\xd0\xdb\x3b\x71\x7d\x2a\xcc\x6f\x8c\xfd\x9b\x3f\x9e\x99\x0c\x87\x57\xc3\x21\x7a\xc4\x0a\x87\x58\xc2\xaf\x28\xd8\x0a\xa2\x60\x25\x00\xe8\x2e\xb8\x1a\x0e\xaf\xb4\x78\xf8\xce\x3f\xad\xac\xf5\x3d\x1c\x52\x40\x52\x89\x2c\x52\x99\x00\xb4\xe2\x02\xa9\xfc\x5d\x80\xa3\x08\xa4\x54\xfc\x15\x98\x34\xdf\x9b\xcc\x9d\xb1\xe7\x20\x6f\xfc\x65\xe6\xa0\xe9\x57\xe4\x3e\x7b\xc8\xf9\x6b\xba\xf0\x16\x16\x1a\x0d\xae\x10\x0a\xf2\x87\x00\x85\x84\x61\xb1\x1b\x8c\xee\xaf\x73\x05\x77\x39\x9b\xdd\x68\x71\x26\x41\xf8\x24\x0e\x10\x61\x6a\x60\x0b\x65\x16\xf3\x00\x29\xc2\x76\x5a\x3a\x2a\xa5\xe8\xd1\xf9\x3a\x5e\xce\x3c\xf4\xe1\xe3\x87\x1c\xc9\x19\xf8\x8a\x24\xd0\x0e\x1d\x09\xc0\x0a\xe2\x00\xc5\x58\x81\x56\xab\x43\x27\xcb\xf9\xdc\x71\x3d\xdf\x9b\x3e\x39\x0b\x6f\xfc\xf4\x3d\x57\x84\xb7\x94\x08\x90\x47\x8a\x7b\x7c\xf5\x40\x78\x0d\x4c\x05\x68\x83\x45\xf4\x82\xc5\xe0\xf6\xd3\xa7\xeb\x1a\xf2\xfb\x7c\xfa\x34\x9e\xff\x40\x7f\x38\x3f\xd0\xa0\xa0\xe9\xfa\xea\x1a\x39\xee\xb7\xa9\xeb\x7c\x9e\x32\xc6\x1f\xbf\x94\xfb\xf9\x7d\x3c\x5f\x38\xde\x67\x8a\x15\x61\xa3\xdf\xfe\x75\xb3\xa7\x69\xc4\x99\xd2\xa7\xb8\x6c\xf4\x12\x6b\x4c\xae\xcd\xb9\x3f\xfa\x2f\xb6\x4d\x0f\xd0\x04\x62\x92\x25\x0a\xde\x54\x7e\xb8\xf1\xc4\x73\xe6\x68\xe1\x78\x28\x53\xab\x07\x34\x79\x9e\xcd\xf4\x17\xf5\x83\x1f\x12\x66\x79\x4d\x1a\xbf\xcb\x80\x55\xce\x49\xdc\x2b\xc2\x13\xb2\x16\x58\x11\xde\x18\x68\x16\xc0\x10\xbd\x01\x21\x09\x67\x26\x78\x46\x23\x8b\x69\x03\x6f\x64\x29\x97\x0b\x90\x19\x55\x01\xca\x4d\xb0\x97\xf4\x85\x8f\x88\x53\x0a\x91\x3e\x2c\x56\x4a\x90\x30\x53\xd0\x22\xff\x34\x6a\x19\xae\x4a\xd1\xc9\x74\x73\xd0\x29\xdd\x77\x74\xfb\x60\x81\x36\x98\x66\x60\x85\x76\xdd\x7f\x93\xf0\xae\xe2\xc2\x49\x78\x57\xf3\xe2\xaa\x33\x56\xf7\x77\x73\xb4\x99\xde\xf8\x68\xb9\xc5\x57\xd8\x75\xb2\x46\x8e\x6f\x6d\x87\x34\x0b\x29\x89\xfc\x57\xd8\x05\x28\xa4\x3c\xb4\xa4\x82\x6c\xb0\x82\x13\xe2\x73\xa4\xf6\x90\xc8\x14\x4b\xb9\xe5\x22\xee\xc4\x66\xa9\xd4\x9e\xd2\x42\x25\x40\xb9\xd7\xde\x7f\xbc\xfe\x3f\xb3\x26\x20\x26\x02\x22\xd5\x89\xb5\x52\xc9\xb0\x96\x0a\xd8\xf8\x98\x12\x2c\x8f\xc2\xfd\xa3\x45\x4c\xc0\x60\x7b\x11\x54\x65\xef\x68\xdd\x1e\x52\xd7\x89\x32\x79\x74\xa1\x5b\x5e\x85\xc6\x4b\xef\xd9\x9f\xba\x93\xb9\xf3\xe4\xb8\x9e\xc9\x9f\x0d\x3c\xb5\x4f\x8d\xb5\x4a\x4a\x11\x45\x7f\x4e\xa6\x0d\x62\x90\x91\x20\xa9\xca\x2f\xcb\xc3\xfe\xee\x3b\xed\xaf\x5a\x99\xaa\x1d\x05\x5f\xbe\x00\x14\x17\xa8\x79\x9b\x7f\xa4\xb8\x51\x5b\xaf\x9c\xab\xae\xb8\x48\xf0\x51\xc9\xf8\x50\x2f\x18\x4d\xe6\x8b\x76\x8d\x35\xae\xa9\x82\xb7\xec\x4c\x35\xbd\x21\xb0\xf5\x23\x9e\xe9\xe2\xab\x41\x5e\xaf\x8d\xf4\xdb\xa5\x3b\xfd\x73\xe9\xe4\x2f\xf7\xf6\x1d\x04\x3d\xf3\xee\x94\xcb\x36\xa9\xc0\xc0\x4a\x8f\x2e\x9c\xc0\xee\x39\x68\xb6\xb6\x7c\xb8\x66\x88\x84\xc7\x64\xb5\xf3\x8b\xd6\xc6\xd4\xb9\xb7\x0d\x38\xed\x07\x3e\x4e\x53\xc0\x02\xb3\x08\x0a\xe8\x5d\x53\x67\xc2\xb8\x48\x4c\x73\x42\x31\x5b\x67\x78\xbd\x47\x37\xad\x2b\x14\xad\x38\xc1\x4f\xf0\x94\xda\x12\xcd\x97\x4a\xfd\x4b\x84\x31\x88\xfd\x94\x4b\x62\xa2\xeb\xe8\x8b\x4b\x77\x31\xfd\xe6\x3a\x8f\x0d\x8b\xef\x1b\x30\x5d\x95\x4a\x85\x93\xb4\x6d\x07\x76\xa8\xfc\x3b\x6b\x5e\x70\x7f\x3b\xdd\xfc\x93\xec\x70\xe8\x71\xba\x25\x82\x8e\xe1\x48\x62\xdf\x38\x6b\xbd\x78\xcc\xdf\xd7\x14\x4a\xa3\x0f\xca\xff\x6f\x0e\x6b\xe7\x98\xc2\x73\x0a\xd4\xde\x8f\x6e\x7a\xd5\x2b\x09\x48\xb8\x82\x15\xa7\x94\x6f\x5b\xc4\x7d\x15\x7e\xb2\x64\xaa\xf5\x4f\x46\xcf\xaf\x4c\x28\x6a\xa0\xd3\xa3\x84\xcb\x25\xbe\xf5\x81\x9e\xf1\xab\xb7\xd5\xae\xce\xb7\xf0\xf5\x21\x40\x7e\x75\x77\xe7\xf6\x6c\x1f\x70\x39\x3e\x8c\xc5\x0f\x1e\xdf\x7f\xb6\x3b\x51\x6d\xd7\x66\xc7\xec\x35\x16\x67\x91\xe2\x86\x8a\xd3\x56\x21\x2c\xe4\x6f\xe7\x00\xf2\x05\x0b\x88\xfd\x4b\xb8\xcb\xb6\xb1\xe2\x6f\x50\x6e\xaf\x37\x76\xd1\x24\x77\x99\x3d\x58\x78\x63\x9d\xb3\xe3\xcd\x86\x79\xc3\xfd\xdd\x7f\x34\x6e\xd8\x6f\xac\x97\x83\x06\xbd\x39\xc2\x36\xa4\x99\xf7\x8a\xd8\x2a\xe7\x6c\x8a\xab\x75\x4e\x7d\x44\x86\xdf\x74\x42\x90\x01\x92\x09\xa6\xf4\x64\x2d\x74\x36\xc9\xb7\x99\x0a\x13\x86\x23\x45\x36\xcd\xf3\xe9\x3e\xd1\xde\xd2\xd1\x3b\x76\x86\x5a\x85\xe1\x04\xde\xdd\x1c\x5e\x1a\x66\x54\x57\x32\x7c\x1d\x16\x32\x8f\xf5\x75\x20\xc1\x84\xe6\x5b\x2a\x7e\x9d\x68\x9c\xd3\xbf\xfb\xd7\x82\xcb\x59\xb0\xa4\x65\x50\xfe\xdf\xab\x28\x94\x26\xce\xe2\x53\x61\x78\x90\x17\xee\x90\x3f\xf9\x27\xc3\xf1\xe4\x7d\xdf\xfa\xcc\x7f\x07\x00\x00\xff\xff\xbe\x79\x68\xa8\x10\x1b\x00\x00")
|
|
||||||
|
|
||||||
func schema_sql() ([]byte, error) {
|
|
||||||
return bindata_read(
|
|
||||||
_schema_sql,
|
|
||||||
"schema.sql",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Asset loads and returns the asset for the given name.
|
|
||||||
// It returns an error if the asset could not be found or
|
|
||||||
// could not be loaded.
|
|
||||||
func Asset(name string) ([]byte, error) {
|
|
||||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
|
||||||
if f, ok := _bindata[cannonicalName]; ok {
|
|
||||||
return f()
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("Asset %s not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssetNames returns the names of the assets.
|
|
||||||
func AssetNames() []string {
|
|
||||||
names := make([]string, 0, len(_bindata))
|
|
||||||
for name := range _bindata {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
|
||||||
var _bindata = map[string]func() ([]byte, error){
|
|
||||||
"schema.sql": schema_sql,
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssetDir returns the file names below a certain
|
|
||||||
// directory embedded in the file by go-bindata.
|
|
||||||
// For example if you run go-bindata on data/... and data contains the
|
|
||||||
// following hierarchy:
|
|
||||||
// data/
|
|
||||||
// foo.txt
|
|
||||||
// img/
|
|
||||||
// a.png
|
|
||||||
// b.png
|
|
||||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
|
||||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
|
||||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
|
||||||
// AssetDir("") will return []string{"data"}.
|
|
||||||
func AssetDir(name string) ([]string, error) {
|
|
||||||
node := _bintree
|
|
||||||
if len(name) != 0 {
|
|
||||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
|
||||||
pathList := strings.Split(cannonicalName, "/")
|
|
||||||
for _, p := range pathList {
|
|
||||||
node = node.Children[p]
|
|
||||||
if node == nil {
|
|
||||||
return nil, fmt.Errorf("Asset %s not found", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if node.Func != nil {
|
|
||||||
return nil, fmt.Errorf("Asset %s not found", name)
|
|
||||||
}
|
|
||||||
rv := make([]string, 0, len(node.Children))
|
|
||||||
for name := range node.Children {
|
|
||||||
rv = append(rv, name)
|
|
||||||
}
|
|
||||||
return rv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type _bintree_t struct {
|
|
||||||
Func func() ([]byte, error)
|
|
||||||
Children map[string]*_bintree_t
|
|
||||||
}
|
|
||||||
|
|
||||||
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
|
|
||||||
"schema.sql": &_bintree_t{schema_sql, map[string]*_bintree_t{}},
|
|
||||||
}}
|
|
296
collections.go
296
collections.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018-2021 Musing Studio LLC.
|
* Copyright © 2018-2022 Musing Studio LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -24,18 +24,28 @@ import (
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/web-core/activitystreams"
|
"github.com/writeas/web-core/activitystreams"
|
||||||
"github.com/writeas/web-core/auth"
|
"github.com/writeas/web-core/auth"
|
||||||
"github.com/writeas/web-core/bots"
|
"github.com/writeas/web-core/bots"
|
||||||
|
"github.com/writeas/web-core/i18n"
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
waposts "github.com/writeas/web-core/posts"
|
"github.com/writeas/web-core/posts"
|
||||||
"github.com/writefreely/writefreely/author"
|
"github.com/writefreely/writefreely/author"
|
||||||
"github.com/writefreely/writefreely/config"
|
"github.com/writefreely/writefreely/config"
|
||||||
"github.com/writefreely/writefreely/page"
|
"github.com/writefreely/writefreely/page"
|
||||||
|
"github.com/writefreely/writefreely/spam"
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
collAttrLetterReplyTo = "letter_reply_to"
|
||||||
|
|
||||||
|
collMaxLengthTitle = 255
|
||||||
|
collMaxLengthDescription = 160
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
// TODO: add Direction to db
|
// TODO: add Direction to db
|
||||||
// TODO: add Language to db
|
// TODO: add Language to db
|
||||||
|
@ -58,6 +68,7 @@ type (
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
|
||||||
Monetization string `json:"monetization_pointer,omitempty"`
|
Monetization string `json:"monetization_pointer,omitempty"`
|
||||||
|
Verification string `json:"verification_link"`
|
||||||
|
|
||||||
db *datastore
|
db *datastore
|
||||||
hostName string
|
hostName string
|
||||||
|
@ -72,11 +83,20 @@ type (
|
||||||
DisplayCollection struct {
|
DisplayCollection struct {
|
||||||
*CollectionObj
|
*CollectionObj
|
||||||
Prefix string
|
Prefix string
|
||||||
|
NavSuffix string
|
||||||
IsTopLevel bool
|
IsTopLevel bool
|
||||||
CurrentPage int
|
CurrentPage int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
Silenced bool
|
Silenced bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CollectionNav struct {
|
||||||
|
*Collection
|
||||||
|
Path string
|
||||||
|
SingleUser bool
|
||||||
|
CanPost bool
|
||||||
|
}
|
||||||
|
|
||||||
SubmittedCollection struct {
|
SubmittedCollection struct {
|
||||||
// Data used for updating a given collection
|
// Data used for updating a given collection
|
||||||
ID int64
|
ID int64
|
||||||
|
@ -87,16 +107,19 @@ type (
|
||||||
Privacy int `schema:"privacy" json:"privacy"`
|
Privacy int `schema:"privacy" json:"privacy"`
|
||||||
Pass string `schema:"password" json:"password"`
|
Pass string `schema:"password" json:"password"`
|
||||||
MathJax bool `schema:"mathjax" json:"mathjax"`
|
MathJax bool `schema:"mathjax" json:"mathjax"`
|
||||||
|
EmailSubs bool `schema:"email_subs" json:"email_subs"`
|
||||||
Handle string `schema:"handle" json:"handle"`
|
Handle string `schema:"handle" json:"handle"`
|
||||||
|
|
||||||
// Actual collection values updated in the DB
|
// Actual collection values updated in the DB
|
||||||
Alias *string `schema:"alias" json:"alias"`
|
Alias *string `schema:"alias" json:"alias"`
|
||||||
Title *string `schema:"title" json:"title"`
|
Title *string `schema:"title" json:"title"`
|
||||||
Description *string `schema:"description" json:"description"`
|
Description *string `schema:"description" json:"description"`
|
||||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
|
StyleSheet *string `schema:"style_sheet" json:"style_sheet"`
|
||||||
Script *sql.NullString `schema:"script" json:"script"`
|
Script *string `schema:"script" json:"script"`
|
||||||
Signature *sql.NullString `schema:"signature" json:"signature"`
|
Signature *string `schema:"signature" json:"signature"`
|
||||||
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
|
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
|
||||||
|
Verification *string `schema:"verification_link" json:"verification_link"`
|
||||||
|
LetterReply *string `schema:"letter_reply" json:"letter_reply"`
|
||||||
Visibility *int `schema:"visibility" json:"public"`
|
Visibility *int `schema:"visibility" json:"public"`
|
||||||
Format *sql.NullString `schema:"format" json:"format"`
|
Format *sql.NullString `schema:"format" json:"format"`
|
||||||
}
|
}
|
||||||
|
@ -258,16 +281,16 @@ func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
|
||||||
|
|
||||||
// PrevPageURL provides a full URL for the previous page of collection posts,
|
// PrevPageURL provides a full URL for the previous page of collection posts,
|
||||||
// returning a /page/N result for pages >1
|
// returning a /page/N result for pages >1
|
||||||
func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
|
func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) string {
|
||||||
u := ""
|
u := ""
|
||||||
if n == 2 {
|
if n == 2 {
|
||||||
// Previous page is 1; no need for /page/ prefix
|
// Previous page is 1; no need for /page/ prefix
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
u = "/"
|
u = navSuffix + "/"
|
||||||
}
|
}
|
||||||
// Else leave off trailing slash
|
// Else leave off trailing slash
|
||||||
} else {
|
} else {
|
||||||
u = fmt.Sprintf("/page/%d", n-1)
|
u = fmt.Sprintf("%s/page/%d", navSuffix, n-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tl {
|
if tl {
|
||||||
|
@ -277,11 +300,12 @@ func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NextPageURL provides a full URL for the next page of collection posts
|
// NextPageURL provides a full URL for the next page of collection posts
|
||||||
func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
|
func (c *Collection) NextPageURL(prefix, navSuffix string, n int, tl bool) string {
|
||||||
|
|
||||||
if tl {
|
if tl {
|
||||||
return fmt.Sprintf("/page/%d", n+1)
|
return fmt.Sprintf("%s/page/%d", navSuffix, n+1)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
|
return fmt.Sprintf("/%s%s%s/page/%d", prefix, c.Alias, navSuffix, n+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Collection) DisplayTitle() string {
|
func (c *Collection) DisplayTitle() string {
|
||||||
|
@ -355,6 +379,10 @@ func (c *Collection) RenderMathJax() bool {
|
||||||
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
|
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Collection) EmailSubsEnabled() bool {
|
||||||
|
return c.db.CollectionHasAttribute(c.ID, "email_subs")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Collection) MonetizationURL() string {
|
func (c *Collection) MonetizationURL() string {
|
||||||
if c.Monetization == "" {
|
if c.Monetization == "" {
|
||||||
return ""
|
return ""
|
||||||
|
@ -362,10 +390,40 @@ func (c *Collection) MonetizationURL() string {
|
||||||
return strings.Replace(c.Monetization, "$", "https://", 1)
|
return strings.Replace(c.Monetization, "$", "https://", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DisplayDescription returns the description with rendered Markdown and HTML.
|
||||||
|
func (c *Collection) DisplayDescription() *template.HTML {
|
||||||
|
if c.Description == "" {
|
||||||
|
s := template.HTML("")
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
t := template.HTML(posts.ApplyBasicAccessibleMarkdown([]byte(c.Description)))
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlainDescription returns the description with all Markdown and HTML removed.
|
||||||
|
func (c *Collection) PlainDescription() string {
|
||||||
|
if c.Description == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
desc := stripHTMLWithoutEscaping(c.Description)
|
||||||
|
desc = stripmd.Strip(desc)
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
|
||||||
func (c CollectionPage) DisplayMonetization() string {
|
func (c CollectionPage) DisplayMonetization() string {
|
||||||
return displayMonetization(c.Monetization, c.Alias)
|
return displayMonetization(c.Monetization, c.Alias)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *DisplayCollection) Direction() string {
|
||||||
|
if c.Language == "" {
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
if i18n.LangIsRTL(c.Language) {
|
||||||
|
return "rtl"
|
||||||
|
}
|
||||||
|
return "ltr"
|
||||||
|
}
|
||||||
|
|
||||||
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
reqJSON := IsJSON(r)
|
reqJSON := IsJSON(r)
|
||||||
alias := r.FormValue("alias")
|
alias := r.FormValue("alias")
|
||||||
|
@ -475,8 +533,7 @@ func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (in
|
||||||
|
|
||||||
// fetchCollection handles the API endpoint for retrieving collection data.
|
// fetchCollection handles the API endpoint for retrieving collection data.
|
||||||
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
accept := r.Header.Get("Accept")
|
if IsActivityPubRequest(r) {
|
||||||
if strings.Contains(accept, "application/activity+json") {
|
|
||||||
return handleFetchCollectionActivities(app, w, r)
|
return handleFetchCollectionActivities(app, w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -551,11 +608,11 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
|
ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
coll := &CollectionObj{Collection: *c, Posts: posts}
|
coll := &CollectionObj{Collection: *c, Posts: ps}
|
||||||
app.db.GetPostsCount(coll, isCollOwner)
|
app.db.GetPostsCount(coll, isCollOwner)
|
||||||
// Strip non-public information
|
// Strip non-public information
|
||||||
coll.Collection.ForPublic()
|
coll.Collection.ForPublic()
|
||||||
|
@ -563,7 +620,7 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
// Transform post bodies if needed
|
// Transform post bodies if needed
|
||||||
if r.FormValue("body") == "html" {
|
if r.FormValue("body") == "html" {
|
||||||
for _, p := range *coll.Posts {
|
for _, p := range *coll.Posts {
|
||||||
p.Content = waposts.ApplyMarkdown([]byte(p.Content))
|
p.Content = posts.ApplyMarkdown([]byte(p.Content))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -577,18 +634,46 @@ type CollectionPage struct {
|
||||||
IsWelcome bool
|
IsWelcome bool
|
||||||
IsOwner bool
|
IsOwner bool
|
||||||
IsCollLoggedIn bool
|
IsCollLoggedIn bool
|
||||||
|
Honeypot string
|
||||||
|
IsSubscriber bool
|
||||||
CanPin bool
|
CanPin bool
|
||||||
Username string
|
Username string
|
||||||
Monetization string
|
Monetization string
|
||||||
|
Flash template.HTML
|
||||||
Collections *[]Collection
|
Collections *[]Collection
|
||||||
PinnedPosts *[]PublicPost
|
PinnedPosts *[]PublicPost
|
||||||
IsAdmin bool
|
|
||||||
CanInvite bool
|
IsAdmin bool
|
||||||
|
CanInvite bool
|
||||||
|
|
||||||
// Helper field for Chorus mode
|
// Helper field for Chorus mode
|
||||||
CollAlias string
|
CollAlias string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TagCollectionPage struct {
|
||||||
|
CollectionPage
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tcp TagCollectionPage) PrevPageURL(prefix string, n int, tl bool) string {
|
||||||
|
u := fmt.Sprintf("/tag:%s", tcp.Tag)
|
||||||
|
if n > 2 {
|
||||||
|
u += fmt.Sprintf("/page/%d", n-1)
|
||||||
|
}
|
||||||
|
if tl {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return "/" + prefix + tcp.Alias + u
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tcp TagCollectionPage) NextPageURL(prefix string, n int, tl bool) string {
|
||||||
|
if tl {
|
||||||
|
return fmt.Sprintf("/tag:%s/page/%d", tcp.Tag, n+1)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("/%s%s/tag:%s/page/%d", prefix, tcp.Alias, tcp.Tag, n+1)
|
||||||
|
}
|
||||||
|
|
||||||
func NewCollectionObj(c *Collection) *CollectionObj {
|
func NewCollectionObj(c *Collection) *CollectionObj {
|
||||||
return &CollectionObj{
|
return &CollectionObj{
|
||||||
Collection: *c,
|
Collection: *c,
|
||||||
|
@ -743,15 +828,18 @@ func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPost
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
|
func newDisplayCollection(c *Collection, cr *collectionReq, page int) (*DisplayCollection, error) {
|
||||||
coll := &DisplayCollection{
|
coll := &DisplayCollection{
|
||||||
CollectionObj: NewCollectionObj(c),
|
CollectionObj: NewCollectionObj(c),
|
||||||
CurrentPage: page,
|
CurrentPage: page,
|
||||||
Prefix: cr.prefix,
|
Prefix: cr.prefix,
|
||||||
IsTopLevel: isSingleUser,
|
IsTopLevel: isSingleUser,
|
||||||
}
|
}
|
||||||
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
err := c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||||
return coll
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return coll, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCollectionPage returns the collection page as an int. If the parsed page value is not
|
// getCollectionPage returns the collection page as an int. If the parsed page value is not
|
||||||
|
@ -794,18 +882,31 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve ActivityStreams data now, if requested
|
// Serve ActivityStreams data now, if requested
|
||||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
if IsActivityPubRequest(r) {
|
||||||
ac := c.PersonObject()
|
ac := c.PersonObject()
|
||||||
ac.Context = []interface{}{activitystreams.Namespace}
|
|
||||||
setCacheControl(w, apCacheTime)
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch extra data about the Collection
|
// Fetch extra data about the Collection
|
||||||
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
|
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
|
||||||
coll := newDisplayCollection(c, cr, page)
|
coll, err := newDisplayCollection(c, cr, page)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
|
var ct PostType
|
||||||
|
if isArchiveView(r) {
|
||||||
|
ct = postArch
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: this number will be off when user has pinned posts but isn't a Pro user
|
||||||
|
ppp := coll.Format.PostsPerPage()
|
||||||
|
if ct == postArch {
|
||||||
|
ppp = postsPerArchPage
|
||||||
|
}
|
||||||
|
|
||||||
|
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(ppp)))
|
||||||
if coll.TotalPages > 0 && page > coll.TotalPages {
|
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||||
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
|
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
|
||||||
if !app.cfg.App.SingleUser {
|
if !app.cfg.App.SingleUser {
|
||||||
|
@ -814,7 +915,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
return impart.HTTPError{http.StatusFound, redirURL}
|
return impart.HTTPError{http.StatusFound, redirURL}
|
||||||
}
|
}
|
||||||
|
|
||||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
|
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false, "")
|
||||||
|
|
||||||
// Serve collection
|
// Serve collection
|
||||||
displayPage := CollectionPage{
|
displayPage := CollectionPage{
|
||||||
|
@ -823,14 +924,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
IsCustomDomain: cr.isCustomDomain,
|
IsCustomDomain: cr.isCustomDomain,
|
||||||
IsWelcome: r.FormValue("greeting") != "",
|
IsWelcome: r.FormValue("greeting") != "",
|
||||||
|
Honeypot: spam.HoneypotFieldName(),
|
||||||
CollAlias: c.Alias,
|
CollAlias: c.Alias,
|
||||||
}
|
}
|
||||||
|
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||||
|
for _, f := range flashes {
|
||||||
|
displayPage.Flash = template.HTML(f)
|
||||||
|
}
|
||||||
displayPage.IsAdmin = u != nil && u.IsAdmin()
|
displayPage.IsAdmin = u != nil && u.IsAdmin()
|
||||||
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
|
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
|
||||||
var owner *User
|
var owner *User
|
||||||
if u != nil {
|
if u != nil {
|
||||||
displayPage.Username = u.Username
|
displayPage.Username = u.Username
|
||||||
displayPage.IsOwner = u.ID == coll.OwnerID
|
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||||
|
displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID)
|
||||||
if displayPage.IsOwner {
|
if displayPage.IsOwner {
|
||||||
// Add in needed information for users viewing their own collection
|
// Add in needed information for users viewing their own collection
|
||||||
owner = u
|
owner = u
|
||||||
|
@ -867,6 +974,9 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
collTmpl := "collection"
|
collTmpl := "collection"
|
||||||
if app.cfg.App.Chorus {
|
if app.cfg.App.Chorus {
|
||||||
collTmpl = "chorus-collection"
|
collTmpl = "chorus-collection"
|
||||||
|
} else if isArchiveView(r) {
|
||||||
|
displayPage.NavSuffix = "/archive/"
|
||||||
|
collTmpl = "collection-archive"
|
||||||
}
|
}
|
||||||
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -893,6 +1003,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isArchiveView(r *http.Request) bool {
|
||||||
|
return strings.HasSuffix(r.RequestURI, "/archive/") || mux.Vars(r)["archive"] == "archive"
|
||||||
|
}
|
||||||
|
|
||||||
func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
|
func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
handle := vars["handle"]
|
handle := vars["handle"]
|
||||||
|
@ -928,7 +1042,23 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
coll := newDisplayCollection(c, cr, page)
|
coll, _ := newDisplayCollection(c, cr, page)
|
||||||
|
|
||||||
|
taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ttlPosts := len(taggedPostIDs)
|
||||||
|
pagePosts := coll.Format.PostsPerPage()
|
||||||
|
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
|
||||||
|
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||||
|
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
|
||||||
|
if !app.cfg.App.SingleUser {
|
||||||
|
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
|
||||||
|
}
|
||||||
|
return impart.HTTPError{http.StatusFound, redirURL}
|
||||||
|
}
|
||||||
|
|
||||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
|
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
|
||||||
if coll.Posts != nil && len(*coll.Posts) == 0 {
|
if coll.Posts != nil && len(*coll.Posts) == 0 {
|
||||||
|
@ -936,10 +1066,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve collection
|
// Serve collection
|
||||||
displayPage := struct {
|
displayPage := TagCollectionPage{
|
||||||
CollectionPage
|
|
||||||
Tag string
|
|
||||||
}{
|
|
||||||
CollectionPage: CollectionPage{
|
CollectionPage: CollectionPage{
|
||||||
DisplayCollection: coll,
|
DisplayCollection: coll,
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
|
@ -991,6 +1118,111 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
lang := vars["lang"]
|
||||||
|
|
||||||
|
cr := &collectionReq{}
|
||||||
|
err := processCollectionRequest(cr, vars, w, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := checkUserForCollection(app, cr, r, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
page := getCollectionPage(vars)
|
||||||
|
|
||||||
|
c, err := processCollectionPermissions(app, cr, u, w, r)
|
||||||
|
if c == nil || err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, _ := newDisplayCollection(c, cr, page)
|
||||||
|
coll.Language = lang
|
||||||
|
coll.NavSuffix = fmt.Sprintf("/lang:%s", lang)
|
||||||
|
|
||||||
|
ttlPosts, err := app.db.GetCollLangTotalPosts(coll.ID, lang)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to getCollLangTotalPosts: %s", err)
|
||||||
|
}
|
||||||
|
pagePosts := coll.Format.PostsPerPage()
|
||||||
|
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
|
||||||
|
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||||
|
redirURL := fmt.Sprintf("/lang:%s/page/%d", lang, coll.TotalPages)
|
||||||
|
if !app.cfg.App.SingleUser {
|
||||||
|
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
|
||||||
|
}
|
||||||
|
return impart.HTTPError{http.StatusFound, redirURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
coll.Posts, _ = app.db.GetLangPosts(app.cfg, c, lang, page, cr.isCollOwner)
|
||||||
|
if err != nil {
|
||||||
|
return ErrCollectionPageNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve collection
|
||||||
|
displayPage := struct {
|
||||||
|
CollectionPage
|
||||||
|
Tag string
|
||||||
|
}{
|
||||||
|
CollectionPage: CollectionPage{
|
||||||
|
DisplayCollection: coll,
|
||||||
|
StaticPage: pageForReq(app, r),
|
||||||
|
IsCustomDomain: cr.isCustomDomain,
|
||||||
|
},
|
||||||
|
Tag: lang,
|
||||||
|
}
|
||||||
|
var owner *User
|
||||||
|
if u != nil {
|
||||||
|
displayPage.Username = u.Username
|
||||||
|
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||||
|
if displayPage.IsOwner {
|
||||||
|
// Add in needed information for users viewing their own collection
|
||||||
|
owner = u
|
||||||
|
displayPage.CanPin = true
|
||||||
|
|
||||||
|
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("unable to fetch collections: %v", err)
|
||||||
|
}
|
||||||
|
displayPage.Collections = pubColls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isOwner := owner != nil
|
||||||
|
if !isOwner {
|
||||||
|
// Current user doesn't own collection; retrieve owner information
|
||||||
|
owner, err = app.db.GetUserByID(coll.OwnerID)
|
||||||
|
if err != nil {
|
||||||
|
// Log the error and just continue
|
||||||
|
log.Error("Error getting user for collection: %v", err)
|
||||||
|
}
|
||||||
|
if owner.IsSilenced() {
|
||||||
|
return ErrCollectionNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
displayPage.Silenced = owner != nil && owner.IsSilenced()
|
||||||
|
displayPage.Owner = owner
|
||||||
|
coll.Owner = displayPage.Owner
|
||||||
|
// Add more data
|
||||||
|
// TODO: fix this mess of collections inside collections
|
||||||
|
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||||
|
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||||
|
|
||||||
|
collTmpl := "collection"
|
||||||
|
if app.cfg.App.Chorus {
|
||||||
|
collTmpl = "chorus-collection"
|
||||||
|
}
|
||||||
|
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to render collection lang page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
|
func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
slug := vars["slug"]
|
slug := vars["slug"]
|
||||||
|
@ -1075,7 +1307,7 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = app.db.UpdateCollection(&c, collAlias)
|
err = app.db.UpdateCollection(app, &c, collAlias)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err, ok := err.(impart.HTTPError); ok {
|
if err, ok := err.(impart.HTTPError); ok {
|
||||||
if reqJSON {
|
if reqJSON {
|
||||||
|
|
|
@ -170,11 +170,26 @@ type (
|
||||||
DisablePasswordAuth bool `ini:"disable_password_auth"`
|
DisablePasswordAuth bool `ini:"disable_password_auth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EmailCfg struct {
|
||||||
|
// SMTP configuration values
|
||||||
|
Host string `ini:"smtp_host"`
|
||||||
|
Port int `ini:"smtp_port"`
|
||||||
|
Username string `ini:"smtp_username"`
|
||||||
|
Password string `ini:"smtp_password"`
|
||||||
|
EnableStartTLS bool `ini:"smtp_enable_start_tls"`
|
||||||
|
|
||||||
|
// Mailgun configuration values
|
||||||
|
Domain string `ini:"domain"`
|
||||||
|
MailgunPrivate string `ini:"mailgun_private"`
|
||||||
|
MailgunEurope bool `ini:"mailgun_europe"`
|
||||||
|
}
|
||||||
|
|
||||||
// Config holds the complete configuration for running a writefreely instance
|
// Config holds the complete configuration for running a writefreely instance
|
||||||
Config struct {
|
Config struct {
|
||||||
Server ServerCfg `ini:"server"`
|
Server ServerCfg `ini:"server"`
|
||||||
Database DatabaseCfg `ini:"database"`
|
Database DatabaseCfg `ini:"database"`
|
||||||
App AppCfg `ini:"app"`
|
App AppCfg `ini:"app"`
|
||||||
|
Email EmailCfg `ini:"email"`
|
||||||
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
||||||
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
||||||
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
||||||
|
@ -235,6 +250,11 @@ func (ac *AppCfg) LandingPath() string {
|
||||||
return ac.Landing
|
return ac.Landing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (lc EmailCfg) Enabled() bool {
|
||||||
|
return (lc.Domain != "" && lc.MailgunPrivate != "") ||
|
||||||
|
lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (ac AppCfg) SignupPath() string {
|
func (ac AppCfg) SignupPath() string {
|
||||||
if !ac.OpenRegistration {
|
if !ac.OpenRegistration {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -12,12 +12,14 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/manifoldco/promptui"
|
"github.com/manifoldco/promptui"
|
||||||
"github.com/mitchellh/go-wordwrap"
|
"github.com/mitchellh/go-wordwrap"
|
||||||
"github.com/writeas/web-core/auth"
|
"github.com/writeas/web-core/auth"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SetupData struct {
|
type SetupData struct {
|
||||||
|
@ -57,7 +59,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
||||||
Success: "{{ . | bold | faint }}: ",
|
Success: "{{ . | bold | faint }}: ",
|
||||||
}
|
}
|
||||||
selTmpls := &promptui.SelectTemplates{
|
selTmpls := &promptui.SelectTemplates{
|
||||||
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`),
|
Selected: `{{.Label}} {{ . | faint }}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var selPrompt promptui.Select
|
var selPrompt promptui.Select
|
||||||
|
@ -80,6 +82,8 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
||||||
isDevEnv := envType == "Development"
|
isDevEnv := envType == "Development"
|
||||||
isStandalone := envType == "Production, standalone"
|
isStandalone := envType == "Production, standalone"
|
||||||
|
|
||||||
|
_, isDocker := os.LookupEnv("WRITEFREELY_DOCKER")
|
||||||
|
|
||||||
data.Config.Server.Dev = isDevEnv
|
data.Config.Server.Dev = isDevEnv
|
||||||
|
|
||||||
if isDevEnv || !isStandalone {
|
if isDevEnv || !isStandalone {
|
||||||
|
@ -150,6 +154,16 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
||||||
data.Config.Server.TLSKeyPath = ""
|
data.Config.Server.TLSKeyPath = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If running in docker:
|
||||||
|
// 1. always bind to 0.0.0.0 instead of localhost
|
||||||
|
// 2. set paths of static files in UNIX manners
|
||||||
|
if !isDevEnv && isDocker {
|
||||||
|
data.Config.Server.TemplatesParentDir = "/usr/share/writefreely"
|
||||||
|
data.Config.Server.StaticParentDir = "/usr/share/writefreely"
|
||||||
|
data.Config.Server.PagesParentDir = "/usr/share/writefreely"
|
||||||
|
data.Config.Server.Bind = "0.0.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
555
database.go
555
database.go
|
@ -14,12 +14,18 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/writeas/web-core/silobridge"
|
|
||||||
wf_db "github.com/writefreely/writefreely/db"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/writeas/monday"
|
||||||
|
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
|
"github.com/writeas/web-core/silobridge"
|
||||||
|
wf_db "github.com/writefreely/writefreely/db"
|
||||||
|
"github.com/writefreely/writefreely/parse"
|
||||||
|
|
||||||
"github.com/guregu/null"
|
"github.com/guregu/null"
|
||||||
"github.com/guregu/null/zero"
|
"github.com/guregu/null/zero"
|
||||||
uuid "github.com/nu7hatch/gouuid"
|
uuid "github.com/nu7hatch/gouuid"
|
||||||
|
@ -95,7 +101,7 @@ type writestore interface {
|
||||||
GetCollection(alias string) (*Collection, error)
|
GetCollection(alias string) (*Collection, error)
|
||||||
GetCollectionForPad(alias string) (*Collection, error)
|
GetCollectionForPad(alias string) (*Collection, error)
|
||||||
GetCollectionByID(id int64) (*Collection, error)
|
GetCollectionByID(id int64) (*Collection, error)
|
||||||
UpdateCollection(c *SubmittedCollection, alias string) error
|
UpdateCollection(app *App, c *SubmittedCollection, alias string) error
|
||||||
DeleteCollection(alias string, userID int64) error
|
DeleteCollection(alias string, userID int64) error
|
||||||
|
|
||||||
UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error
|
UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error
|
||||||
|
@ -111,8 +117,10 @@ type writestore interface {
|
||||||
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
|
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
|
||||||
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
|
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
|
||||||
|
|
||||||
GetPostsCount(c *CollectionObj, includeFuture bool)
|
GetPostLikeCounts(postID string) (int64, error)
|
||||||
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
|
GetPostsCount(c *CollectionObj, includeFuture bool) error
|
||||||
|
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool, contentType PostType) (*[]PublicPost, error)
|
||||||
|
GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error)
|
||||||
GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error)
|
GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error)
|
||||||
|
|
||||||
GetAPFollowers(c *Collection) (*[]RemoteUser, error)
|
GetAPFollowers(c *Collection) (*[]RemoteUser, error)
|
||||||
|
@ -171,6 +179,13 @@ func (db *datastore) upsert(indexedCols ...string) string {
|
||||||
return "ON DUPLICATE KEY UPDATE"
|
return "ON DUPLICATE KEY UPDATE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) dateAdd(l int, unit string) string {
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
return fmt.Sprintf("DATETIME('now', '%d %s')", l, unit)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("DATE_ADD(NOW(), INTERVAL %d %s)", l, unit)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) dateSub(l int, unit string) string {
|
func (db *datastore) dateSub(l int, unit string) string {
|
||||||
if db.driverName == driverSQLite {
|
if db.driverName == driverSQLite {
|
||||||
return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit)
|
return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit)
|
||||||
|
@ -332,7 +347,7 @@ func (db *datastore) IsUserSilenced(id int64) (bool, error) {
|
||||||
err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
|
err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return false, fmt.Errorf("is user silenced: %v", ErrUserNotFound)
|
return false, ErrUserNotFound
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Error("Couldn't SELECT user status: %v", err)
|
log.Error("Couldn't SELECT user status: %v", err)
|
||||||
return false, fmt.Errorf("is user silenced: %v", err)
|
return false, fmt.Errorf("is user silenced: %v", err)
|
||||||
|
@ -564,7 +579,7 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
|
||||||
|
|
||||||
expirationVal := "NULL"
|
expirationVal := "NULL"
|
||||||
if validSecs > 0 {
|
if validSecs > 0 {
|
||||||
expirationVal = fmt.Sprintf("DATE_ADD("+db.now()+", INTERVAL %d SECOND)", validSecs)
|
expirationVal = db.dateAdd(validSecs, "SECOND")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+expirationVal+")", string(binTok), userID, oneTime)
|
_, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+expirationVal+")", string(binTok), userID, oneTime)
|
||||||
|
@ -576,6 +591,37 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
|
||||||
return u.String(), nil
|
return u.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) CreatePasswordResetToken(userID int64) (string, error) {
|
||||||
|
t := id.Generate62RandomString(32)
|
||||||
|
|
||||||
|
_, err := db.Exec("INSERT INTO password_resets (user_id, token, used, created) VALUES (?, ?, 0, "+db.now()+")", userID, t)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't INSERT password_resets: %v", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetUserFromPasswordReset(token string) int64 {
|
||||||
|
var userID int64
|
||||||
|
err := db.QueryRow("SELECT user_id FROM password_resets WHERE token = ? AND used = 0 AND created > "+db.dateSub(3, "HOUR"), token).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) ConsumePasswordResetToken(t string) error {
|
||||||
|
_, err := db.Exec("UPDATE password_resets SET used = 1 WHERE token = ?", t)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't UPDATE password_resets: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) {
|
func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) {
|
||||||
var userID, collID int64 = -1, -1
|
var userID, collID int64 = -1, -1
|
||||||
var coll *Collection
|
var coll *Collection
|
||||||
|
@ -814,6 +860,7 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll
|
||||||
c.Format = format.String
|
c.Format = format.String
|
||||||
c.Public = c.IsPublic()
|
c.Public = c.IsPublic()
|
||||||
c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer")
|
c.Monetization = db.GetCollectionAttribute(c.ID, "monetization_pointer")
|
||||||
|
c.Verification = db.GetCollectionAttribute(c.ID, "verification_link")
|
||||||
|
|
||||||
c.db = db
|
c.db = db
|
||||||
|
|
||||||
|
@ -850,13 +897,21 @@ func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) {
|
||||||
return db.GetCollectionBy("host = ?", host)
|
return db.GetCollectionBy("host = ?", host)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) error {
|
func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias string) error {
|
||||||
|
// Truncate fields correctly, so we don't get "Data too long for column" errors in MySQL (writefreely#600)
|
||||||
|
if c.Title != nil {
|
||||||
|
*c.Title = parse.Truncate(*c.Title, collMaxLengthTitle)
|
||||||
|
}
|
||||||
|
if c.Description != nil {
|
||||||
|
*c.Description = parse.Truncate(*c.Description, collMaxLengthDescription)
|
||||||
|
}
|
||||||
|
|
||||||
q := query.NewUpdate().
|
q := query.NewUpdate().
|
||||||
SetStringPtr(c.Title, "title").
|
SetStringPtr(c.Title, "title").
|
||||||
SetStringPtr(c.Description, "description").
|
SetStringPtr(c.Description, "description").
|
||||||
SetNullString(c.StyleSheet, "style_sheet").
|
SetStringPtr(c.StyleSheet, "style_sheet").
|
||||||
SetNullString(c.Script, "script").
|
SetStringPtr(c.Script, "script").
|
||||||
SetNullString(c.Signature, "post_signature")
|
SetStringPtr(c.Signature, "post_signature")
|
||||||
|
|
||||||
if c.Format != nil {
|
if c.Format != nil {
|
||||||
cf := &CollectionFormat{Format: c.Format.String}
|
cf := &CollectionFormat{Format: c.Format.String}
|
||||||
|
@ -909,6 +964,44 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update Verification link value
|
||||||
|
if c.Verification != nil {
|
||||||
|
skipUpdate := false
|
||||||
|
if *c.Verification != "" {
|
||||||
|
// Strip away any excess spaces
|
||||||
|
trimmed := strings.TrimSpace(*c.Verification)
|
||||||
|
if strings.HasPrefix(trimmed, "@") && strings.Count(trimmed, "@") == 2 {
|
||||||
|
// This looks like a fediverse handle, so resolve profile URL
|
||||||
|
profileURL, err := GetProfileURLFromHandle(app, trimmed)
|
||||||
|
if err != nil || profileURL == "" {
|
||||||
|
log.Error("Couldn't find user %s: %v", trimmed, err)
|
||||||
|
skipUpdate = true
|
||||||
|
} else {
|
||||||
|
c.Verification = &profileURL
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !strings.HasPrefix(trimmed, "http") {
|
||||||
|
trimmed = "https://" + trimmed
|
||||||
|
}
|
||||||
|
vu, err := url.Parse(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
// Value appears invalid, so don't update
|
||||||
|
skipUpdate = true
|
||||||
|
} else {
|
||||||
|
s := vu.String()
|
||||||
|
c.Verification = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !skipUpdate {
|
||||||
|
err = db.SetCollectionAttribute(collID, "verification_link", *c.Verification)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to insert verification_link value: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update Monetization value
|
// Update Monetization value
|
||||||
if c.Monetization != nil {
|
if c.Monetization != nil {
|
||||||
skipUpdate := false
|
skipUpdate := false
|
||||||
|
@ -932,6 +1025,40 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update EmailSub value
|
||||||
|
if c.EmailSubs {
|
||||||
|
err = db.SetCollectionAttribute(collID, "email_subs", "1")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to insert email_subs value: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skipUpdate := false
|
||||||
|
if c.LetterReply != nil {
|
||||||
|
// Strip away any excess spaces
|
||||||
|
trimmed := strings.TrimSpace(*c.LetterReply)
|
||||||
|
// Only update value when it contains "@"
|
||||||
|
if strings.IndexRune(trimmed, '@') > 0 {
|
||||||
|
c.LetterReply = &trimmed
|
||||||
|
} else {
|
||||||
|
// Value appears invalid, so don't update
|
||||||
|
skipUpdate = true
|
||||||
|
}
|
||||||
|
if !skipUpdate {
|
||||||
|
err = db.SetCollectionAttribute(collID, collAttrLetterReplyTo, *c.LetterReply)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to insert %s value: %v", collAttrLetterReplyTo, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "email_subs")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to delete email_subs value: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update rest of the collection data
|
// Update rest of the collection data
|
||||||
if q.Updates != "" {
|
if q.Updates != "" {
|
||||||
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
|
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
|
||||||
|
@ -1050,6 +1177,12 @@ func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error)
|
||||||
return nil, ErrPostUnpublished
|
return nil, ErrPostUnpublished
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get additional information needed before processing post data
|
||||||
|
p.LikeCount, err = db.GetPostLikeCounts(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
res := p.processPost()
|
res := p.processPost()
|
||||||
if ownerName.Valid {
|
if ownerName.Valid {
|
||||||
res.Owner = &PublicUser{Username: ownerName.String}
|
res.Owner = &PublicUser{Username: ownerName.String}
|
||||||
|
@ -1112,10 +1245,22 @@ func (db *datastore) GetPostProperty(id string, collectionID int64, property str
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetPostLikeCounts(postID string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM remote_likes WHERE post_id = ?", postID).Scan(&count)
|
||||||
|
switch {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
count = 0
|
||||||
|
case err != nil:
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetPostsCount modifies the CollectionObj to include the correct number of
|
// GetPostsCount modifies the CollectionObj to include the correct number of
|
||||||
// standard (non-pinned) posts. It will return future posts if `includeFuture`
|
// standard (non-pinned) posts. It will return future posts if `includeFuture`
|
||||||
// is true.
|
// is true.
|
||||||
func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
|
func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) error {
|
||||||
var count int64
|
var count int64
|
||||||
timeCondition := ""
|
timeCondition := ""
|
||||||
if !includeFuture {
|
if !includeFuture {
|
||||||
|
@ -1128,16 +1273,18 @@ func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Error("Failed selecting from collections: %v", err)
|
log.Error("Failed selecting from collections: %v", err)
|
||||||
c.TotalPosts = 0
|
c.TotalPosts = 0
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
c.TotalPosts = int(count)
|
c.TotalPosts = int(count)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPosts retrieves all posts for the given Collection.
|
// GetPosts retrieves all posts for the given Collection.
|
||||||
// It will return future posts if `includeFuture` is true.
|
// It will return future posts if `includeFuture` is true.
|
||||||
// It will include only standard (non-pinned) posts unless `includePinned` is true.
|
// It will include only standard (non-pinned) posts unless `includePinned` is true.
|
||||||
// TODO: change includeFuture to isOwner, since that's how it's used
|
// TODO: change includeFuture to isOwner, since that's how it's used
|
||||||
func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) {
|
func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool, contentType PostType) (*[]PublicPost, error) {
|
||||||
collID := c.ID
|
collID := c.ID
|
||||||
|
|
||||||
cf := c.NewFormat()
|
cf := c.NewFormat()
|
||||||
|
@ -1151,6 +1298,9 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
||||||
if page == 0 {
|
if page == 0 {
|
||||||
start = 0
|
start = 0
|
||||||
pagePosts = 1000
|
pagePosts = 1000
|
||||||
|
} else if contentType == postArch {
|
||||||
|
pagePosts = postsPerArchPage
|
||||||
|
start = page*pagePosts - pagePosts
|
||||||
}
|
}
|
||||||
|
|
||||||
limitStr := ""
|
limitStr := ""
|
||||||
|
@ -1165,6 +1315,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
||||||
if !includePinned {
|
if !includePinned {
|
||||||
pinnedCondition = "AND pinned_position IS NULL"
|
pinnedCondition = "AND pinned_position IS NULL"
|
||||||
}
|
}
|
||||||
|
// FUTURE: handle different post contentType's here
|
||||||
rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? "+pinnedCondition+" "+timeCondition+" ORDER BY created "+order+limitStr, collID)
|
rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? "+pinnedCondition+" "+timeCondition+" ORDER BY created "+order+limitStr, collID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed selecting from posts: %v", err)
|
log.Error("Failed selecting from posts: %v", err)
|
||||||
|
@ -1185,7 +1336,13 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
||||||
p.augmentContent(c)
|
p.augmentContent(c)
|
||||||
p.formatContent(cfg, c, includeFuture, false)
|
p.formatContent(cfg, c, includeFuture, false)
|
||||||
|
|
||||||
posts = append(posts, p.processPost())
|
pubPost := p.processPost()
|
||||||
|
if contentType == postArch {
|
||||||
|
// Overwrite DisplayDate with special Archive page version
|
||||||
|
loc := monday.FuzzyLocale(pubPost.Language.String)
|
||||||
|
pubPost.DisplayDate = monday.Format(pubPost.Created, monday.LongNoYrFormatsByLocale[loc], loc)
|
||||||
|
}
|
||||||
|
posts = append(posts, pubPost)
|
||||||
}
|
}
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1195,6 +1352,51 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
||||||
return &posts, nil
|
return &posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetAllPostsTaggedIDs(c *Collection, tag string, includeFuture bool) ([]string, error) {
|
||||||
|
collID := c.ID
|
||||||
|
|
||||||
|
cf := c.NewFormat()
|
||||||
|
order := "DESC"
|
||||||
|
if cf.Ascending() {
|
||||||
|
order = "ASC"
|
||||||
|
}
|
||||||
|
|
||||||
|
timeCondition := ""
|
||||||
|
if !includeFuture {
|
||||||
|
timeCondition = "AND created <= " + db.now()
|
||||||
|
}
|
||||||
|
var rows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
rows, err = db.Query("SELECT id FROM posts WHERE collection_id = ? AND LOWER(content) regexp ? "+timeCondition+" ORDER BY created "+order, collID, `.*#`+strings.ToLower(tag)+`\b.*`)
|
||||||
|
} else {
|
||||||
|
rows, err = db.Query("SELECT id FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order, collID, "#"+strings.ToLower(tag)+"[[:>:]]")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed selecting tagged posts: %v", err)
|
||||||
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve tagged collection posts."}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
ids := []string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
err = rows.Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed scanning row: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error after Next() on rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetPostsTagged retrieves all posts on the given Collection that contain the
|
// GetPostsTagged retrieves all posts on the given Collection that contain the
|
||||||
// given tag.
|
// given tag.
|
||||||
// It will return future posts if `includeFuture` is true.
|
// It will return future posts if `includeFuture` is true.
|
||||||
|
@ -1260,8 +1462,76 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin
|
||||||
return &posts, nil
|
return &posts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetCollLangTotalPosts(collID int64, lang string) (uint64, error) {
|
||||||
|
var articles uint64
|
||||||
|
err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND language = ? AND created <= "+db.now(), collID, lang).Scan(&articles)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
log.Error("Couldn't get total lang posts count for collection %d: %v", collID, err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return articles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetLangPosts(cfg *config.Config, c *Collection, lang string, page int, includeFuture bool) (*[]PublicPost, error) {
|
||||||
|
collID := c.ID
|
||||||
|
|
||||||
|
cf := c.NewFormat()
|
||||||
|
order := "DESC"
|
||||||
|
if cf.Ascending() {
|
||||||
|
order = "ASC"
|
||||||
|
}
|
||||||
|
|
||||||
|
pagePosts := cf.PostsPerPage()
|
||||||
|
start := page*pagePosts - pagePosts
|
||||||
|
if page == 0 {
|
||||||
|
start = 0
|
||||||
|
pagePosts = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := ""
|
||||||
|
if page > 0 {
|
||||||
|
limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts)
|
||||||
|
}
|
||||||
|
timeCondition := ""
|
||||||
|
if !includeFuture {
|
||||||
|
timeCondition = "AND created <= " + db.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(`SELECT `+postCols+`
|
||||||
|
FROM posts
|
||||||
|
WHERE collection_id = ? AND language = ? `+timeCondition+`
|
||||||
|
ORDER BY created `+order+limitStr, collID, lang)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed selecting from posts: %v", err)
|
||||||
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// TODO: extract this common row scanning logic for queries using `postCols`
|
||||||
|
posts := []PublicPost{}
|
||||||
|
for rows.Next() {
|
||||||
|
p := &Post{}
|
||||||
|
err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed scanning row: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
p.extractData()
|
||||||
|
p.augmentContent(c)
|
||||||
|
p.formatContent(cfg, c, includeFuture, false)
|
||||||
|
|
||||||
|
posts = append(posts, p.processPost())
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error after Next() on rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
|
func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
|
||||||
rows, err := db.Query("SELECT actor_id, inbox, shared_inbox FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID)
|
rows, err := db.Query("SELECT actor_id, inbox, shared_inbox, f.created FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed selecting from followers: %v", err)
|
log.Error("Failed selecting from followers: %v", err)
|
||||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."}
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."}
|
||||||
|
@ -1271,7 +1541,7 @@ func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
|
||||||
followers := []RemoteUser{}
|
followers := []RemoteUser{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
f := RemoteUser{}
|
f := RemoteUser{}
|
||||||
err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox)
|
err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox, &f.Created)
|
||||||
followers = append(followers, f)
|
followers = append(followers, f)
|
||||||
}
|
}
|
||||||
return &followers, nil
|
return &followers, nil
|
||||||
|
@ -1745,7 +2015,7 @@ func (db *datastore) GetMeStats(u *User) userMeStats {
|
||||||
|
|
||||||
func (db *datastore) GetTotalCollections() (collCount int64, err error) {
|
func (db *datastore) GetTotalCollections() (collCount int64, err error) {
|
||||||
err = db.QueryRow(`
|
err = db.QueryRow(`
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM collections c
|
FROM collections c
|
||||||
LEFT JOIN users u ON u.id = c.owner_id
|
LEFT JOIN users u ON u.id = c.owner_id
|
||||||
WHERE u.status = 0`).Scan(&collCount)
|
WHERE u.status = 0`).Scan(&collCount)
|
||||||
|
@ -1774,7 +2044,7 @@ func (db *datastore) GetTopPosts(u *User, alias string, hostName string) (*[]Pub
|
||||||
where = " AND alias = ?"
|
where = " AND alias = ?"
|
||||||
params = append(params, alias)
|
params = append(params, alias)
|
||||||
}
|
}
|
||||||
rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON p.collection_id = c.id WHERE p.owner_id = ?"+where+" ORDER BY p.view_count DESC, created DESC LIMIT 25", params...)
|
rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, p.content, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON p.collection_id = c.id WHERE p.owner_id = ?"+where+" ORDER BY p.view_count DESC, created DESC LIMIT 25", params...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed selecting from posts: %v", err)
|
log.Error("Failed selecting from posts: %v", err)
|
||||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user top posts."}
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user top posts."}
|
||||||
|
@ -1788,7 +2058,7 @@ func (db *datastore) GetTopPosts(u *User, alias string, hostName string) (*[]Pub
|
||||||
c := Collection{}
|
c := Collection{}
|
||||||
var alias, title, description sql.NullString
|
var alias, title, description sql.NullString
|
||||||
var views sql.NullInt64
|
var views sql.NullInt64
|
||||||
err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &alias, &title, &description, &views)
|
err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &p.Content, &alias, &title, &description, &views)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed scanning User.getPosts() row: %v", err)
|
log.Error("Failed scanning User.getPosts() row: %v", err)
|
||||||
gotErr = true
|
gotErr = true
|
||||||
|
@ -2228,7 +2498,7 @@ func (db *datastore) GetCollectionAttribute(id int64, attr string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error {
|
func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error {
|
||||||
_, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", id, attr, v)
|
_, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", id, attr, v, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to INSERT into collectionattributes: %v", err)
|
log.Error("Unable to INSERT into collectionattributes: %v", err)
|
||||||
return err
|
return err
|
||||||
|
@ -2765,6 +3035,7 @@ func handleFailedPostInsert(err error) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated: use GetProfileURLFromHandle() instead, which returns user-facing URL instead of actor_id
|
||||||
func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) {
|
func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, error) {
|
||||||
handle = strings.TrimLeft(handle, "@")
|
handle = strings.TrimLeft(handle, "@")
|
||||||
actorIRI := ""
|
actorIRI := ""
|
||||||
|
@ -2813,3 +3084,247 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string,
|
||||||
}
|
}
|
||||||
return actorIRI, nil
|
return actorIRI, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) AddEmailSubscription(collID, userID int64, email string, confirmed bool) (*EmailSubscriber, error) {
|
||||||
|
friendlyChars := "0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz"
|
||||||
|
subID := id.GenerateRandomString(friendlyChars, 8)
|
||||||
|
token := id.GenerateRandomString(friendlyChars, 16)
|
||||||
|
emailVal := sql.NullString{
|
||||||
|
String: email,
|
||||||
|
Valid: email != "",
|
||||||
|
}
|
||||||
|
userIDVal := sql.NullInt64{
|
||||||
|
Int64: userID,
|
||||||
|
Valid: userID > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec("INSERT INTO emailsubscribers (id, collection_id, user_id, email, subscribed, token, confirmed) VALUES (?, ?, ?, ?, "+db.now()+", ?, ?)", subID, collID, userIDVal, emailVal, token, confirmed)
|
||||||
|
if err != nil {
|
||||||
|
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||||
|
if mysqlErr.Number == mySQLErrDuplicateKey {
|
||||||
|
// Duplicate, so just return existing subscriber information
|
||||||
|
log.Info("Duplicate subscriber for email %s, user %d; returning existing subscriber", email, userID)
|
||||||
|
return db.FetchEmailSubscriber(email, userID, collID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EmailSubscriber{
|
||||||
|
ID: subID,
|
||||||
|
CollID: collID,
|
||||||
|
UserID: userIDVal,
|
||||||
|
Email: emailVal,
|
||||||
|
Token: token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) IsEmailSubscriber(email string, userID, collID int64) bool {
|
||||||
|
var dummy int
|
||||||
|
var err error
|
||||||
|
if email != "" {
|
||||||
|
err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID).Scan(&dummy)
|
||||||
|
} else {
|
||||||
|
err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID).Scan(&dummy)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
return false
|
||||||
|
case err != nil:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetEmailSubscribers(collID int64, reqConfirmed bool) ([]*EmailSubscriber, error) {
|
||||||
|
cond := ""
|
||||||
|
if reqConfirmed {
|
||||||
|
cond = " AND confirmed = 1"
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export
|
||||||
|
FROM emailsubscribers s
|
||||||
|
LEFT JOIN users u
|
||||||
|
ON u.id = user_id
|
||||||
|
WHERE collection_id = ?`+cond+`
|
||||||
|
ORDER BY subscribed DESC`, collID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed selecting email subscribers for collection %d: %v", collID, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var subs []*EmailSubscriber
|
||||||
|
for rows.Next() {
|
||||||
|
s := &EmailSubscriber{}
|
||||||
|
err = rows.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.acctEmail, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed scanning row from email subscribers: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) FetchEmailSubscriberEmail(subID, token string) (string, error) {
|
||||||
|
var email sql.NullString
|
||||||
|
// TODO: return user email if there's a user_id ?
|
||||||
|
err := db.QueryRow("SELECT email FROM emailsubscribers WHERE id = ? AND token = ?", subID, token).Scan(&email)
|
||||||
|
switch {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
return "", fmt.Errorf("Subscriber doesn't exist or token is invalid.")
|
||||||
|
case err != nil:
|
||||||
|
log.Error("Couldn't SELECT email from emailsubscribers: %v", err)
|
||||||
|
return "", fmt.Errorf("Something went very wrong.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return email.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) FetchEmailSubscriber(email string, userID, collID int64) (*EmailSubscriber, error) {
|
||||||
|
const emailSubCols = "id, collection_id, user_id, email, subscribed, token, confirmed, allow_export"
|
||||||
|
|
||||||
|
s := &EmailSubscriber{}
|
||||||
|
var row *sql.Row
|
||||||
|
if email != "" {
|
||||||
|
row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID)
|
||||||
|
} else {
|
||||||
|
row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID)
|
||||||
|
}
|
||||||
|
err := row.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport)
|
||||||
|
switch {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
return nil, nil
|
||||||
|
case err != nil:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) DeleteEmailSubscriber(subID, token string) error {
|
||||||
|
res, err := db.Exec("DELETE FROM emailsubscribers WHERE id = ? AND token = ?", subID, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := res.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return impart.HTTPError{http.StatusNotFound, "Invalid token, or subscriber doesn't exist"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) DeleteEmailSubscriberByUser(email string, userID, collID int64) error {
|
||||||
|
var res sql.Result
|
||||||
|
var err error
|
||||||
|
if email != "" {
|
||||||
|
res, err = db.Exec("DELETE FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID)
|
||||||
|
} else {
|
||||||
|
res, err = db.Exec("DELETE FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := res.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return impart.HTTPError{http.StatusNotFound, "Subscriber doesn't exist"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) UpdateSubscriberConfirmed(subID, token string) error {
|
||||||
|
email, err := db.FetchEmailSubscriberEmail(subID, token)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Didn't fetch email subscriber: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: ensure all addresses with original name are also confirmed, e.g. matt+fake@write.as and matt@write.as are now confirmed
|
||||||
|
_, err = db.Exec("UPDATE emailsubscribers SET confirmed = 1 WHERE email = ?", email)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Could not update email subscriber confirmation status: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) IsSubscriberConfirmed(email string) bool {
|
||||||
|
var dummy int64
|
||||||
|
err := db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND confirmed = 1", email).Scan(&dummy)
|
||||||
|
switch {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
return false
|
||||||
|
case err != nil:
|
||||||
|
log.Error("Couldn't SELECT in isSubscriberConfirmed: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) InsertJob(j *PostJob) error {
|
||||||
|
res, err := db.Exec("INSERT INTO publishjobs (post_id, action, delay) VALUES (?, ?, ?)", j.PostID, j.Action, j.Delay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
jobID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[jobs] Couldn't get last insert ID! %s", err)
|
||||||
|
}
|
||||||
|
log.Info("[jobs] Queued %s job #%d for post %s, delayed %d minutes", j.Action, jobID, j.PostID, j.Delay)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) UpdateJobForPost(postID string, delay int64) error {
|
||||||
|
_, err := db.Exec("UPDATE publishjobs SET delay = ? WHERE post_id = ?", delay, postID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Unable to update publish job: %s", err)
|
||||||
|
}
|
||||||
|
log.Info("Updated job for post %s: delay %d", postID, delay)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) DeleteJob(id int64) error {
|
||||||
|
_, err := db.Exec("DELETE FROM publishjobs WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Info("[job #%d] Deleted.", id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) DeleteJobByPost(postID string) error {
|
||||||
|
_, err := db.Exec("DELETE FROM publishjobs WHERE post_id = ?", postID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Info("[job] Deleted job for post %s", postID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetJobsToRun(action string) ([]*PostJob, error) {
|
||||||
|
timeWhere := "created < DATE_SUB(NOW(), INTERVAL delay MINUTE) AND created > DATE_SUB(NOW(), INTERVAL delay + 5 MINUTE)"
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
timeWhere = "created < DATETIME('now', '-' || delay || ' MINUTE') AND created > DATETIME('now', '-' || (delay+5) || ' MINUTE')"
|
||||||
|
}
|
||||||
|
rows, err := db.Query(`SELECT pj.id, post_id, action, delay
|
||||||
|
FROM publishjobs pj
|
||||||
|
INNER JOIN posts p
|
||||||
|
ON post_id = p.id
|
||||||
|
WHERE action = ? AND `+timeWhere+`
|
||||||
|
ORDER BY created ASC`, action)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed selecting from publishjobs: %v", err)
|
||||||
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve publish jobs."}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
jobs := []*PostJob{}
|
||||||
|
for rows.Next() {
|
||||||
|
j := &PostJob{}
|
||||||
|
err = rows.Scan(&j.ID, &j.PostID, &j.Action, &j.Delay)
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
return jobs, nil
|
||||||
|
}
|
||||||
|
|
49
database_activitypub.go
Normal file
49
database_activitypub.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2024 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"github.com/writeas/web-core/activitystreams"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func apAddRemoteUser(app *App, t *sql.Tx, fullActor *activitystreams.Person) (int64, error) {
|
||||||
|
// Add remote user locally, since it wasn't found before
|
||||||
|
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return -1, fmt.Errorf("couldn't add new remoteuser in DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteUserID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return -1, fmt.Errorf("no lastinsertid for followers, rolling back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add in key
|
||||||
|
_, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, remoteUserID, fullActor.PublicKey.PublicKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
if !app.db.isDuplicateKeyErr(err) {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
||||||
|
return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
||||||
|
return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteUserID, nil
|
||||||
|
}
|
|
@ -247,10 +247,7 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
|
||||||
}
|
}
|
||||||
things = append(things, columnStr)
|
things = append(things, columnStr)
|
||||||
}
|
}
|
||||||
for _, constraint := range b.Constraints {
|
things = append(things, b.Constraints...)
|
||||||
things = append(things, constraint)
|
|
||||||
}
|
|
||||||
|
|
||||||
if thingLen := len(things); thingLen > 0 {
|
if thingLen := len(things); thingLen > 0 {
|
||||||
str.WriteString(" ( ")
|
str.WriteString(" ( ")
|
||||||
for i, thing := range things {
|
for i, thing := range things {
|
||||||
|
|
25
docker-compose.prod.yml
Normal file
25
docker-compose.prod.yml
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: writefreely
|
||||||
|
container_name: writefreely
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8080:8080
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: lscr.io/linuxserver/mariadb
|
||||||
|
container_name: writefreely-mariadb
|
||||||
|
volumes:
|
||||||
|
- ./db:/config
|
||||||
|
environment:
|
||||||
|
- PUID=65534
|
||||||
|
- PGID=65534
|
||||||
|
- TZ=Etc/UTC
|
||||||
|
- MYSQL_DATABASE=writefreely
|
||||||
|
- MYSQL_USER=writefreely
|
||||||
|
- MYSQL_PASSWORD=P@ssw0rd
|
||||||
|
restart: unless-stopped
|
477
email.go
Normal file
477
email.go
Normal file
|
@ -0,0 +1,477 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2019-2021 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"github.com/writefreely/writefreely/mailer"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aymerick/douceur/inliner"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||||
|
"github.com/writeas/impart"
|
||||||
|
"github.com/writeas/web-core/data"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
"github.com/writefreely/writefreely/key"
|
||||||
|
"github.com/writefreely/writefreely/spam"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
emailSendDelay = 15
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
SubmittedSubscription struct {
|
||||||
|
CollAlias string
|
||||||
|
UserID int64
|
||||||
|
|
||||||
|
Email string `schema:"email" json:"email"`
|
||||||
|
Web bool `schema:"web" json:"web"`
|
||||||
|
Slug string `schema:"slug" json:"slug"`
|
||||||
|
From string `schema:"from" json:"from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailSubscriber struct {
|
||||||
|
ID string
|
||||||
|
CollID int64
|
||||||
|
UserID sql.NullInt64
|
||||||
|
Email sql.NullString
|
||||||
|
Subscribed time.Time
|
||||||
|
Token string
|
||||||
|
Confirmed bool
|
||||||
|
AllowExport bool
|
||||||
|
acctEmail sql.NullString
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string {
|
||||||
|
if !es.UserID.Valid || es.Email.Valid {
|
||||||
|
return es.Email.String
|
||||||
|
}
|
||||||
|
|
||||||
|
decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error decrypting user email: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(decEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (es *EmailSubscriber) SubscribedFriendly() string {
|
||||||
|
return es.Subscribed.Format("January 2, 2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
reqJSON := IsJSON(r)
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
var err error
|
||||||
|
|
||||||
|
ss := SubmittedSubscription{
|
||||||
|
CollAlias: vars["alias"],
|
||||||
|
}
|
||||||
|
u := getUserSession(app, r)
|
||||||
|
if u != nil {
|
||||||
|
ss.UserID = u.ID
|
||||||
|
}
|
||||||
|
if reqJSON {
|
||||||
|
// Decode JSON request
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
err = decoder.Decode(&ss)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't parse new subscription JSON request: %v\n", err)
|
||||||
|
return ErrBadJSON
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't parse new subscription form request: %v\n", err)
|
||||||
|
return ErrBadFormData
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.formDecoder.Decode(&ss, r.PostForm)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Continuing, but error decoding new subscription form request: %v\n", err)
|
||||||
|
//return ErrBadFormData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := app.db.GetCollection(ss.CollAlias)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("getCollection: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.hostName = app.cfg.App.Host
|
||||||
|
|
||||||
|
from := c.CanonicalURL()
|
||||||
|
isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
|
if isAuthorBanned {
|
||||||
|
log.Info("Author is silenced, so subscription is blocked.")
|
||||||
|
return impart.HTTPError{http.StatusFound, from}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ss.Web {
|
||||||
|
if u != nil && u.ID == c.OwnerID {
|
||||||
|
from = "/" + c.Alias + "/"
|
||||||
|
}
|
||||||
|
from += ss.Slug
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" {
|
||||||
|
log.Info("Honeypot field was filled out! Not subscribing.")
|
||||||
|
return impart.HTTPError{http.StatusFound, from}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ss.Email == "" && ss.UserID < 1 {
|
||||||
|
log.Info("No subscriber data. Not subscribing.")
|
||||||
|
return impart.HTTPError{http.StatusFound, from}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed := app.db.IsSubscriberConfirmed(ss.Email)
|
||||||
|
es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("addEmailSubscription: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send confirmation email if needed
|
||||||
|
if !confirmed {
|
||||||
|
err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to send subscription confirmation email: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ss.Web {
|
||||||
|
session, err := app.sessionStore.Get(r, userEmailCookieName)
|
||||||
|
if err != nil {
|
||||||
|
// The cookie should still save, even if there's an error.
|
||||||
|
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
|
||||||
|
log.Error("Getting user email cookie: %v; ignoring", err)
|
||||||
|
}
|
||||||
|
if confirmed {
|
||||||
|
addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil)
|
||||||
|
} else {
|
||||||
|
addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil)
|
||||||
|
}
|
||||||
|
session.Values[userEmailCookieVal] = ss.Email
|
||||||
|
err = session.Save(r, w)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("save email cookie: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return impart.HTTPError{http.StatusFound, from}
|
||||||
|
}
|
||||||
|
return impart.WriteSuccess(w, "", http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
alias := collectionAliasFromReq(r)
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
subID := vars["subscriber"]
|
||||||
|
email := r.FormValue("email")
|
||||||
|
token := r.FormValue("t")
|
||||||
|
slug := r.FormValue("slug")
|
||||||
|
isWeb := r.Method == "GET"
|
||||||
|
|
||||||
|
// Display collection if this is a collection
|
||||||
|
var c *Collection
|
||||||
|
var err error
|
||||||
|
if app.cfg.App.SingleUser {
|
||||||
|
c, err = app.db.GetCollectionByID(1)
|
||||||
|
} else {
|
||||||
|
c, err = app.db.GetCollection(alias)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Get collection: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
from := c.CanonicalURL()
|
||||||
|
|
||||||
|
if subID != "" {
|
||||||
|
// User unsubscribing via email, so assume action is taken by either current
|
||||||
|
// user or not current user, and only use the request's information to
|
||||||
|
// satisfy this unsubscribe, i.e. subscriberID and token.
|
||||||
|
err = app.db.DeleteEmailSubscriber(subID, token)
|
||||||
|
} else {
|
||||||
|
// User unsubscribing through the web app, so assume action is taken by
|
||||||
|
// currently-auth'd user.
|
||||||
|
var userID int64
|
||||||
|
u := getUserSession(app, r)
|
||||||
|
if u != nil {
|
||||||
|
// User is logged in
|
||||||
|
userID = u.ID
|
||||||
|
if userID == c.OwnerID {
|
||||||
|
from = "/" + c.Alias + "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if email == "" && userID <= 0 {
|
||||||
|
// Get email address from saved cookie
|
||||||
|
session, err := app.sessionStore.Get(r, userEmailCookieName)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to get email cookie: %s", err)
|
||||||
|
} else {
|
||||||
|
email = session.Values[userEmailCookieVal].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if email == "" && userID <= 0 {
|
||||||
|
err = fmt.Errorf("No subscriber given.")
|
||||||
|
log.Error("Not deleting subscription: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to delete subscriber: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isWeb {
|
||||||
|
from += slug
|
||||||
|
addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil)
|
||||||
|
return impart.HTTPError{http.StatusFound, from}
|
||||||
|
}
|
||||||
|
return impart.WriteSuccess(w, "", http.StatusAccepted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
alias := collectionAliasFromReq(r)
|
||||||
|
subID := mux.Vars(r)["subscriber"]
|
||||||
|
token := r.FormValue("t")
|
||||||
|
|
||||||
|
var c *Collection
|
||||||
|
var err error
|
||||||
|
if app.cfg.App.SingleUser {
|
||||||
|
c, err = app.db.GetCollectionByID(1)
|
||||||
|
} else {
|
||||||
|
c, err = app.db.GetCollection(alias)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Get collection: %s", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
from := c.CanonicalURL()
|
||||||
|
|
||||||
|
err = app.db.UpdateSubscriberConfirmed(subID, token)
|
||||||
|
if err != nil {
|
||||||
|
addSessionFlash(app, w, r, err.Error(), nil)
|
||||||
|
return impart.HTTPError{http.StatusFound, from}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil)
|
||||||
|
return impart.HTTPError{http.StatusFound, from}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailPost(app *App, p *PublicPost, collID int64) error {
|
||||||
|
p.augmentContent()
|
||||||
|
|
||||||
|
// Do some shortcode replacement.
|
||||||
|
// Since the user is receiving this email, we can assume they're subscribed via email.
|
||||||
|
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -1)
|
||||||
|
|
||||||
|
if p.HTMLContent == template.HTML("") {
|
||||||
|
p.formatContent(app.cfg, false, false)
|
||||||
|
}
|
||||||
|
p.augmentReadingDestination()
|
||||||
|
|
||||||
|
title := p.Title.String
|
||||||
|
if title != "" {
|
||||||
|
title = p.Title.String + "\n\n"
|
||||||
|
}
|
||||||
|
plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content)
|
||||||
|
plainMsg += `
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to.
|
||||||
|
|
||||||
|
Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%`
|
||||||
|
|
||||||
|
mlr, err := mailer.New(app.cfg.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m, err := mlr.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo)
|
||||||
|
if replyTo != "" {
|
||||||
|
m.SetReplyTo(replyTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
subs, err := app.db.GetEmailSubscribers(collID, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to get email subscribers: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(subs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if title != "" {
|
||||||
|
title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
|
||||||
|
}
|
||||||
|
m.AddTag("New post")
|
||||||
|
|
||||||
|
fontFam := "Lora, Palatino, Baskerville, serif"
|
||||||
|
if p.IsSans() {
|
||||||
|
fontFam = `"Open Sans", Tahoma, Arial, sans-serif`
|
||||||
|
} else if p.IsMonospace() {
|
||||||
|
fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: move this to a templated file and LESS-generated stylesheet
|
||||||
|
fullHTML := `<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-size: 120%;
|
||||||
|
font-family: ` + fontFam + `;
|
||||||
|
margin: 1em 2em;
|
||||||
|
}
|
||||||
|
#article {
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6, p, code {
|
||||||
|
display: inline
|
||||||
|
}
|
||||||
|
img, iframe, video {
|
||||||
|
max-width: 100%
|
||||||
|
}
|
||||||
|
#title {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
div#footer {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 35em;
|
||||||
|
margin: 2em auto;
|
||||||
|
}
|
||||||
|
div#footer p {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.86em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
margin: 2em 1em;
|
||||||
|
}
|
||||||
|
p#emailsub {
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block !important;
|
||||||
|
width: 100%;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
|
||||||
|
|
||||||
|
` + string(p.HTMLContent) + `</div>
|
||||||
|
<hr />
|
||||||
|
<div id="footer">
|
||||||
|
<p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
|
||||||
|
<p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// inline CSS
|
||||||
|
html, err := inliner.Inline(fullHTML)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to inline email HTML: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.SetHTML(html)
|
||||||
|
|
||||||
|
log.Info("[email] Adding %d recipient(s)", len(subs))
|
||||||
|
for _, s := range subs {
|
||||||
|
e := s.FinalEmail(app.keys)
|
||||||
|
log.Info("[email] Adding %s", e)
|
||||||
|
err = m.AddRecipientAndVariables(e, map[string]string{
|
||||||
|
"id": s.ID,
|
||||||
|
"to": e,
|
||||||
|
"token": s.Token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to add receipient %s: %s", e, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mlr.Send(m)
|
||||||
|
log.Info("[email] Email sent")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to send post email: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error {
|
||||||
|
if email == "" {
|
||||||
|
return fmt.Errorf("You must supply an email to verify.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send email
|
||||||
|
mlr, err := mailer.New(app.cfg.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser):
|
||||||
|
|
||||||
|
` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + `
|
||||||
|
|
||||||
|
If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.`
|
||||||
|
m, err := mlr.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.AddTag("Email Verification")
|
||||||
|
|
||||||
|
m.SetHTML(`<html>
|
||||||
|
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||||
|
<div style="font-size: 1.2em;">
|
||||||
|
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
|
||||||
|
<p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p>
|
||||||
|
<p>If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
err = mlr.Send(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -119,7 +119,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
|
||||||
var collObjs []CollectionObj
|
var collObjs []CollectionObj
|
||||||
for _, c := range *colls {
|
for _, c := range *colls {
|
||||||
co := &CollectionObj{Collection: c}
|
co := &CollectionObj{Collection: c}
|
||||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true)
|
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("unable to get collection posts: %v", err)
|
log.Error("unable to get collection posts: %v", err)
|
||||||
}
|
}
|
||||||
|
|
2
feed.go
2
feed.go
|
@ -67,7 +67,7 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
||||||
if tag != "" {
|
if tag != "" {
|
||||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
|
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
|
||||||
} else {
|
} else {
|
||||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false)
|
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
author := ""
|
author := ""
|
||||||
|
|
95
go.mod
95
go.mod
|
@ -1,83 +1,98 @@
|
||||||
module github.com/writefreely/writefreely
|
module github.com/writefreely/writefreely
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.0
|
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||||
github.com/fatih/color v1.10.0
|
github.com/aymerick/douceur v0.2.0
|
||||||
github.com/go-ini/ini v1.66.4
|
github.com/clbanning/mxj v1.8.4 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.6.0
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/gorilla/csrf v1.7.0
|
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
|
||||||
github.com/gorilla/feeds v1.1.1
|
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
|
||||||
github.com/gorilla/schema v1.2.0
|
github.com/fatih/color v1.17.0
|
||||||
github.com/gorilla/sessions v1.2.0
|
github.com/go-ini/ini v1.67.0
|
||||||
github.com/guregu/null v3.5.0+incompatible
|
github.com/go-sql-driver/mysql v1.8.1
|
||||||
|
github.com/go-test/deep v1.0.1 // indirect
|
||||||
|
github.com/gobuffalo/envy v1.9.0 // indirect
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||||
|
github.com/gorilla/csrf v1.7.2
|
||||||
|
github.com/gorilla/feeds v1.1.2
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/gorilla/schema v1.4.1
|
||||||
|
github.com/gorilla/sessions v1.3.0
|
||||||
|
github.com/gosimple/slug v1.14.0
|
||||||
|
github.com/guregu/null v4.0.0+incompatible
|
||||||
github.com/hashicorp/go-multierror v1.1.1
|
github.com/hashicorp/go-multierror v1.1.1
|
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
||||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||||
github.com/manifoldco/promptui v0.8.0
|
github.com/mailgun/mailgun-go v2.0.0+incompatible
|
||||||
github.com/mattn/go-sqlite3 v1.14.6
|
github.com/manifoldco/promptui v0.9.0
|
||||||
github.com/microcosm-cc/bluemonday v1.0.21
|
github.com/mattn/go-sqlite3 v1.14.21
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1
|
github.com/mitchellh/go-wordwrap v1.0.1
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||||
github.com/urfave/cli/v2 v2.5.1
|
github.com/onsi/gomega v1.13.0 // indirect
|
||||||
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
||||||
|
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
||||||
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
||||||
|
github.com/stretchr/testify v1.9.0
|
||||||
|
github.com/urfave/cli/v2 v2.27.4
|
||||||
github.com/writeas/activity v0.1.2
|
github.com/writeas/activity v0.1.2
|
||||||
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481
|
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835
|
||||||
github.com/writeas/go-strip-markdown/v2 v2.1.1
|
github.com/writeas/go-strip-markdown/v2 v2.1.1
|
||||||
github.com/writeas/go-webfinger v1.1.0
|
github.com/writeas/go-webfinger v1.1.0
|
||||||
github.com/writeas/httpsig v1.0.0
|
github.com/writeas/httpsig v1.0.0
|
||||||
github.com/writeas/impart v1.1.1
|
github.com/writeas/impart v1.1.1
|
||||||
github.com/writeas/import v0.2.1
|
github.com/writeas/import v0.2.1
|
||||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
github.com/writeas/monday v1.3.0
|
||||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
|
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
|
||||||
github.com/writeas/slug v1.2.0
|
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431
|
||||||
github.com/writeas/web-core v1.4.1
|
|
||||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b
|
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0
|
github.com/writefreely/go-nodeinfo v1.2.0
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
golang.org/x/crypto v0.28.0
|
||||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b
|
golang.org/x/net v0.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.as/core/socks v1.0.0 // indirect
|
code.as/core/socks v1.0.0 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||||
github.com/beevik/etree v1.1.0 // indirect
|
github.com/beevik/etree v1.1.0 // indirect
|
||||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect
|
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
||||||
github.com/clbanning/mxj v1.8.4 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
|
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
|
||||||
github.com/fatih/structs v1.1.0 // indirect
|
github.com/fatih/structs v1.1.0 // indirect
|
||||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
|
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
|
||||||
github.com/go-test/deep v1.0.1 // indirect
|
|
||||||
github.com/gofrs/uuid v3.3.0+incompatible // indirect
|
github.com/gofrs/uuid v3.3.0+incompatible // indirect
|
||||||
github.com/gologme/log v1.2.0 // indirect
|
github.com/gologme/log v1.2.0 // indirect
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/gorilla/css v1.0.0 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
github.com/gosimple/unidecode v1.0.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||||
|
github.com/joho/godotenv v1.3.0 // indirect
|
||||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.8 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.12 // indirect
|
|
||||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/sasha-s/go-deadlock v0.3.1 // indirect
|
github.com/sasha-s/go-deadlock v0.3.1 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
|
||||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible // indirect
|
|
||||||
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
|
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
|
||||||
github.com/writeas/openssl-go v1.0.0 // indirect
|
github.com/writeas/openssl-go v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
|
github.com/writeas/slug v1.2.0 // indirect
|
||||||
golang.org/x/text v0.3.7 // indirect
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||||
|
golang.org/x/sys v0.26.0 // indirect
|
||||||
|
golang.org/x/text v0.19.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
go 1.19
|
go 1.21
|
||||||
|
|
276
go.sum
276
go.sum
|
@ -1,12 +1,18 @@
|
||||||
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
||||||
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
|
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
|
||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||||
|
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||||
|
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||||
|
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||||
|
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
|
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
|
||||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||||
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
|
|
||||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||||
|
@ -16,90 +22,135 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
||||||
github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
||||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
|
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
|
||||||
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
|
||||||
|
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
|
||||||
|
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
|
||||||
|
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk=
|
||||||
|
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
|
||||||
|
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
||||||
|
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
||||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
|
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
|
||||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||||
github.com/go-ini/ini v1.66.4 h1:dKjMqkcbkzfddhIhyglTPgMoJnkvmG+bSLrU9cTHc5M=
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
github.com/go-ini/ini v1.66.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||||
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||||
|
github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE=
|
||||||
|
github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
|
||||||
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
|
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
|
||||||
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
|
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
|
||||||
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
github.com/gorilla/csrf v1.7.0 h1:mMPjV5/3Zd460xCavIkppUdvnl5fPXMpv2uz2Zyg7/Y=
|
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||||
github.com/gorilla/csrf v1.7.0/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
|
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
|
||||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||||
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
|
||||||
|
github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
||||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
|
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
||||||
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||||
github.com/guregu/null v3.5.0+incompatible h1:fSdvRTQtmBA4B4YDZXhLtxTIJZYuUxBFTTHS4B9djG4=
|
github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es=
|
||||||
github.com/guregu/null v3.5.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
|
||||||
|
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
|
||||||
|
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||||
|
github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw=
|
||||||
|
github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
||||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
||||||
|
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||||
|
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
|
|
||||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
|
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
|
||||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
|
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
|
||||||
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
|
||||||
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
|
github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU=
|
||||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||||
github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo=
|
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||||
github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
|
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.5/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
|
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
|
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
|
||||||
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
|
||||||
|
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||||
|
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||||
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
|
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
|
github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak=
|
||||||
|
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
|
||||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
|
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
|
||||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
|
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
@ -108,6 +159,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
||||||
|
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
|
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
|
||||||
|
@ -119,16 +173,19 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/urfave/cli/v2 v2.5.1 h1:YKwdkyA0xTBzOaP2G0DVxBnCheHGP+Y9VbKAs4K1Ess=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/urfave/cli/v2 v2.5.1/go.mod h1:oDzoM7pVwz6wHn5ogWgFUU1s4VJayeQS+aEZDqXIEJs=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
|
||||||
|
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||||
|
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
|
||||||
|
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
|
||||||
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
|
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
|
||||||
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
|
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
|
||||||
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0=
|
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw=
|
||||||
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
|
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
|
||||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
|
||||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
|
||||||
github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
|
github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
|
||||||
github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA=
|
github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA=
|
||||||
github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
||||||
|
@ -143,8 +200,8 @@ github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o=
|
||||||
github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||||
github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
|
github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
|
||||||
github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
|
github.com/writeas/monday v1.3.0 h1:h51wJ0DULXIDZ1w11zutLL7YCBRO5LznXISSzqVLZeA=
|
||||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
|
github.com/writeas/monday v1.3.0/go.mod h1:9/CdGLDdIeAvzvf4oeihX++PE/qXUT2+tUlPQKCfRWY=
|
||||||
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
|
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
|
||||||
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
||||||
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||||
|
@ -152,40 +209,125 @@ github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt
|
||||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||||
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
||||||
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
||||||
github.com/writeas/web-core v1.4.1 h1:mdDwZepEyQb76j8gNUIPblV7SUIXi4WQ0h3Xl0ZwKT4=
|
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431 h1:ruqL2u87k504PXkR/fC4DcfZyyHmCindlpjOQKmyOsY=
|
||||||
github.com/writeas/web-core v1.4.1/go.mod h1:MTWDZWikeG063S9IrI6ekvu3N2tJEVRpZuU4kAWg1DY=
|
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431/go.mod h1:7+idL4Y4woF7MnUfNX2mvkaQ8nLIJXths2y5iYPtA3k=
|
||||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0=
|
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0=
|
||||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M=
|
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M=
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
||||||
|
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||||
|
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||||
|
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||||
|
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b h1:6e93nYa3hNqAvLr0pD4PN1fFS+gKzp2zAXqrnTCstqU=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||||
|
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
|
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
|
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
|
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||||
|
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
||||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
@ -111,7 +111,7 @@ func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request
|
||||||
w.WriteInfo(c.Description)
|
w.WriteInfo(c.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
|
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
14
handle.go
14
handle.go
|
@ -155,8 +155,14 @@ func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
|
||||||
err := f(h.app.App(), u, w, r)
|
err := f(h.app.App(), u, w, r)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
status = http.StatusOK
|
status = http.StatusOK
|
||||||
} else if err, ok := err.(impart.HTTPError); ok {
|
} else if impErr, ok := err.(impart.HTTPError); ok {
|
||||||
status = err.Status
|
status = impErr.Status
|
||||||
|
if impErr == ErrUserNotFound {
|
||||||
|
log.Info("Logged-in user not found. Logging out.")
|
||||||
|
sendRedirect(w, http.StatusFound, "/me/logout?to="+h.app.App().cfg.App.LandingPath())
|
||||||
|
// Reset err so handleHTTPError does nothing
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
status = http.StatusInternalServerError
|
status = http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -256,7 +262,7 @@ func apiAuth(app *App, r *http.Request) (*User, error) {
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// optionaAPIAuth is used for endpoints that accept authenticated requests via
|
// optionalAPIAuth is used for endpoints that accept authenticated requests via
|
||||||
// Authorization header or cookie, unlike apiAuth. It returns a different err
|
// Authorization header or cookie, unlike apiAuth. It returns a different err
|
||||||
// in the case where no Authorization header is present.
|
// in the case where no Authorization header is present.
|
||||||
func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
|
func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
|
||||||
|
@ -812,7 +818,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
||||||
return
|
return
|
||||||
} else if err.Status == http.StatusNotFound {
|
} else if err.Status == http.StatusNotFound {
|
||||||
w.WriteHeader(err.Status)
|
w.WriteHeader(err.Status)
|
||||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
if IsActivityPubRequest(r) {
|
||||||
// This is a fediverse request; simply return the header
|
// This is a fediverse request; simply return the header
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,9 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
|
||||||
|
|
||||||
p.Silenced, err = app.db.IsUserSilenced(u.ID)
|
p.Silenced, err = app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
log.Error("view invites: %v", err)
|
log.Error("view invites: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
72
jobs.go
Normal file
72
jobs.go
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PostJob struct {
|
||||||
|
ID int64
|
||||||
|
PostID string
|
||||||
|
Action string
|
||||||
|
Delay int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func addJob(app *App, p *PublicPost, action string, delay int64) error {
|
||||||
|
j := &PostJob{
|
||||||
|
PostID: p.ID,
|
||||||
|
Action: action,
|
||||||
|
Delay: delay,
|
||||||
|
}
|
||||||
|
return app.db.InsertJob(j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startPublishJobsQueue(app *App) {
|
||||||
|
t := time.NewTicker(62 * time.Second)
|
||||||
|
for {
|
||||||
|
log.Info("[jobs] Done.")
|
||||||
|
<-t.C
|
||||||
|
log.Info("[jobs] Fetching email publish jobs...")
|
||||||
|
jobs, err := app.db.GetJobsToRun("email")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[jobs] %s - Skipping.", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Info("[jobs] Running %d email publish jobs...", len(jobs))
|
||||||
|
err = runJobs(app, jobs, true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[jobs] Failed: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runJobs(app *App, jobs []*PostJob, reqColl bool) error {
|
||||||
|
for _, j := range jobs {
|
||||||
|
p, err := app.db.GetPost(j.PostID, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Info("[job #%d] Unable to get post: %s", j.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !p.CollectionID.Valid && reqColl {
|
||||||
|
log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID)
|
||||||
|
app.db.DeleteJob(j.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coll, err := app.db.GetCollectionByID(p.CollectionID.Int64)
|
||||||
|
if err != nil {
|
||||||
|
log.Info("[job #%d] Unable to get collection: %s", j.ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coll.hostName = app.cfg.App.Host
|
||||||
|
coll.ForPublic()
|
||||||
|
p.Collection = &CollectionObj{Collection: *coll}
|
||||||
|
err = emailPost(app, p, p.Collection.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[job #%d] Failed to email post %s", j.ID, p.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Info("[job #%d] Success for post %s.", j.ID, p.ID)
|
||||||
|
app.db.DeleteJob(j.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
5
keys.go
5
keys.go
|
@ -13,7 +13,6 @@ package writefreely
|
||||||
import (
|
import (
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
"github.com/writefreely/writefreely/key"
|
"github.com/writefreely/writefreely/key"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
@ -52,7 +51,7 @@ func initKeyPaths(app *App) {
|
||||||
func generateKey(path string) error {
|
func generateKey(path string) error {
|
||||||
// Check if key file exists
|
// Check if key file exists
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
log.Info("%s already exists. rm the file if you understand the consquences.", path)
|
log.Info("%s already exists. rm the file if you understand the consequences.", path)
|
||||||
return nil
|
return nil
|
||||||
} else if !os.IsNotExist(err) {
|
} else if !os.IsNotExist(err) {
|
||||||
log.Error("%s", err)
|
log.Error("%s", err)
|
||||||
|
@ -65,7 +64,7 @@ func generateKey(path string) error {
|
||||||
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
|
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = ioutil.WriteFile(path, b, 0600)
|
err = os.WriteFile(path, b, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("FAILED writing file: %s", err)
|
log.Error("FAILED writing file: %s", err)
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -60,6 +60,35 @@ nav#admin {
|
||||||
background: #ccc;
|
background: #ccc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.sub {
|
||||||
|
margin: 1em 0 2em;
|
||||||
|
a:not(.toggle) {
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px transparent solid;
|
||||||
|
.rounded(0);
|
||||||
|
padding: 0.5em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: @primary;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&.selected {
|
||||||
|
color: @primary;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom-color: @primary;
|
||||||
|
}
|
||||||
|
&+a {
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.toggle {
|
||||||
|
margin-top: -0.5em;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-actions {
|
.admin-actions {
|
||||||
|
|
|
@ -5,7 +5,7 @@ body {
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: #111;
|
color: #111;
|
||||||
|
|
||||||
h1, header h2 {
|
h1, header h2 {
|
||||||
a {
|
a {
|
||||||
color: @headerTextColor;
|
color: @headerTextColor;
|
||||||
|
@ -210,6 +210,10 @@ body {
|
||||||
pre {
|
pre {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.flash {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&#subpage {
|
&#subpage {
|
||||||
#wrapper {
|
#wrapper {
|
||||||
|
@ -668,6 +672,26 @@ body#collection article, body#subpage article {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#wrapper.archive {
|
||||||
|
h1 {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
body#post article {
|
body#post article {
|
||||||
p.badge {
|
p.badge {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
|
@ -827,6 +851,9 @@ input {
|
||||||
margin: 0 auto 3em;
|
margin: 0 auto 3em;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
|
|
||||||
|
&.toosmall {
|
||||||
|
max-width: 25em;
|
||||||
|
}
|
||||||
&.tight {
|
&.tight {
|
||||||
max-width: 30em;
|
max-width: 30em;
|
||||||
}
|
}
|
||||||
|
@ -1094,7 +1121,7 @@ li {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.errors {
|
ul.errors {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
li.urgent {
|
li.urgent {
|
||||||
|
@ -1597,6 +1624,18 @@ pre.code-block {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#emailsub {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
p#emailsub {
|
||||||
|
display: inline-block !important;
|
||||||
|
width: 100%;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
#subscribe-btn {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
#org-nav {
|
#org-nav {
|
||||||
font-family: @sansFont;
|
font-family: @sansFont;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
|
@ -1618,4 +1657,4 @@ pre.code-block {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
font-family: 'Open Sans';
|
font-family: 'Open Sans';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: optional;
|
|
||||||
src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */
|
src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */
|
||||||
src: local('Open Sans'), local('OpenSans'),
|
src: local('Open Sans'), local('OpenSans'),
|
||||||
url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||||
|
@ -17,7 +16,6 @@
|
||||||
font-family: 'Open Sans';
|
font-family: 'Open Sans';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: optional;
|
|
||||||
src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */
|
src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */
|
||||||
src: local('Open Sans Bold'), local('OpenSans-Bold'),
|
src: local('Open Sans Bold'), local('OpenSans-Bold'),
|
||||||
url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||||
|
@ -31,7 +29,6 @@
|
||||||
font-family: 'Lora';
|
font-family: 'Lora';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: optional;
|
|
||||||
src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */
|
src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */
|
||||||
src: local('Lora'), local('Lora-Regular'),
|
src: local('Lora'), local('Lora-Regular'),
|
||||||
url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||||
|
@ -44,7 +41,6 @@
|
||||||
font-family: 'Lora';
|
font-family: 'Lora';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-display: optional;
|
|
||||||
src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */
|
src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */
|
||||||
src: local('Lora Bold'), local('Lora-Bold'),
|
src: local('Lora Bold'), local('Lora-Bold'),
|
||||||
url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||||
|
@ -56,7 +52,6 @@
|
||||||
font-family: 'Lora';
|
font-family: 'Lora';
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: optional;
|
|
||||||
src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
|
src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
|
||||||
src: local('Lora Italic'), local('Lora-Italic'),
|
src: local('Lora Italic'), local('Lora-Italic'),
|
||||||
url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||||
|
|
|
@ -12,7 +12,7 @@ body {
|
||||||
&:hover {
|
&:hover {
|
||||||
.opacity(1);
|
.opacity(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.6em;
|
font-size: 1.6em;
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
article, pre, .hljs {
|
article, pre, .hljs, #wrapper.archive ul {
|
||||||
padding: 0.5em 2rem 1.5em;
|
padding: 0.5em 2rem 1.5em;
|
||||||
}
|
}
|
||||||
body#post article, pre, .hljs {
|
body#post article, pre, .hljs {
|
||||||
|
|
181
mailer/mailer.go
Normal file
181
mailer/mailer.go
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2024 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package mailer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/mailgun/mailgun-go"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
"github.com/writefreely/writefreely/config"
|
||||||
|
mail "github.com/xhit/go-simple-mail/v2"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// Mailer holds configurations for the preferred mailing provider.
|
||||||
|
Mailer struct {
|
||||||
|
smtp *mail.SMTPServer
|
||||||
|
mailGun *mailgun.MailgunImpl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message holds the email contents and metadata for the preferred mailing provider.
|
||||||
|
Message struct {
|
||||||
|
mgMsg *mailgun.Message
|
||||||
|
smtpMsg *SmtpMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
SmtpMessage struct {
|
||||||
|
from string
|
||||||
|
replyTo string
|
||||||
|
subject string
|
||||||
|
recipients []Recipient
|
||||||
|
html string
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
Recipient struct {
|
||||||
|
email string
|
||||||
|
vars map[string]string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a new Mailer from the instance's config.EmailCfg, returning an error if not properly configured.
|
||||||
|
func New(eCfg config.EmailCfg) (*Mailer, error) {
|
||||||
|
m := &Mailer{}
|
||||||
|
if eCfg.Domain != "" && eCfg.MailgunPrivate != "" {
|
||||||
|
m.mailGun = mailgun.NewMailgun(eCfg.Domain, eCfg.MailgunPrivate)
|
||||||
|
if eCfg.MailgunEurope {
|
||||||
|
m.mailGun.SetAPIBase("https://api.eu.mailgun.net/v3")
|
||||||
|
}
|
||||||
|
} else if eCfg.Username != "" && eCfg.Password != "" && eCfg.Host != "" && eCfg.Port > 0 {
|
||||||
|
m.smtp = mail.NewSMTPClient()
|
||||||
|
m.smtp.Host = eCfg.Host
|
||||||
|
m.smtp.Port = eCfg.Port
|
||||||
|
m.smtp.Username = eCfg.Username
|
||||||
|
m.smtp.Password = eCfg.Password
|
||||||
|
if eCfg.EnableStartTLS {
|
||||||
|
m.smtp.Encryption = mail.EncryptionSTARTTLS
|
||||||
|
}
|
||||||
|
// To allow sending multiple email
|
||||||
|
m.smtp.KeepAlive = true
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("no email provider is configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMessage creates a new Message from the given parameters.
|
||||||
|
func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, error) {
|
||||||
|
msg := &Message{}
|
||||||
|
if m.mailGun != nil {
|
||||||
|
msg.mgMsg = m.mailGun.NewMessage(from, subject, text, to...)
|
||||||
|
} else if m.smtp != nil {
|
||||||
|
msg.smtpMsg = &SmtpMessage{
|
||||||
|
from: from,
|
||||||
|
replyTo: "",
|
||||||
|
subject: subject,
|
||||||
|
recipients: make([]Recipient, len(to)),
|
||||||
|
html: "",
|
||||||
|
text: text,
|
||||||
|
}
|
||||||
|
for _, r := range to {
|
||||||
|
msg.smtpMsg.recipients = append(msg.smtpMsg.recipients, Recipient{r, make(map[string]string)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHTML sets the body of the message.
|
||||||
|
func (m *Message) SetHTML(html string) {
|
||||||
|
if m.smtpMsg != nil {
|
||||||
|
m.smtpMsg.html = html
|
||||||
|
} else if m.mgMsg != nil {
|
||||||
|
m.mgMsg.SetHtml(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) SetReplyTo(replyTo string) {
|
||||||
|
if m.smtpMsg != nil {
|
||||||
|
m.smtpMsg.replyTo = replyTo
|
||||||
|
} else {
|
||||||
|
m.mgMsg.SetReplyTo(replyTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTag attaches a tag to the Message for providers that support it.
|
||||||
|
func (m *Message) AddTag(tag string) {
|
||||||
|
if m.mgMsg != nil {
|
||||||
|
m.mgMsg.AddTag(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) AddRecipientAndVariables(r string, vars map[string]string) error {
|
||||||
|
if m.smtpMsg != nil {
|
||||||
|
m.smtpMsg.recipients = append(m.smtpMsg.recipients, Recipient{r, vars})
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
varsInterfaces := make(map[string]interface{}, len(vars))
|
||||||
|
for k, v := range vars {
|
||||||
|
varsInterfaces[k] = v
|
||||||
|
}
|
||||||
|
return m.mgMsg.AddRecipientAndVariables(r, varsInterfaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends the given message via the preferred provider.
|
||||||
|
func (m *Mailer) Send(msg *Message) error {
|
||||||
|
if m.smtp != nil {
|
||||||
|
client, err := m.smtp.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
emailSent := false
|
||||||
|
for _, r := range msg.smtpMsg.recipients {
|
||||||
|
customMsg := mail.NewMSG()
|
||||||
|
customMsg.SetFrom(msg.smtpMsg.from)
|
||||||
|
if msg.smtpMsg.replyTo != "" {
|
||||||
|
customMsg.SetReplyTo(msg.smtpMsg.replyTo)
|
||||||
|
}
|
||||||
|
customMsg.SetSubject(msg.smtpMsg.subject)
|
||||||
|
customMsg.AddTo(r.email)
|
||||||
|
cText := msg.smtpMsg.text
|
||||||
|
cHtml := msg.smtpMsg.html
|
||||||
|
for v, value := range r.vars {
|
||||||
|
placeHolder := fmt.Sprintf("%%recipient.%s%%", v)
|
||||||
|
cText = strings.ReplaceAll(cText, placeHolder, value)
|
||||||
|
cHtml = strings.ReplaceAll(cHtml, placeHolder, value)
|
||||||
|
}
|
||||||
|
customMsg.SetBody(mail.TextHTML, cHtml)
|
||||||
|
customMsg.AddAlternative(mail.TextPlain, cText)
|
||||||
|
e := customMsg.Error
|
||||||
|
if e == nil {
|
||||||
|
e = customMsg.Send(client)
|
||||||
|
}
|
||||||
|
if e == nil {
|
||||||
|
emailSent = true
|
||||||
|
} else {
|
||||||
|
log.Error("Unable to send email to %s: %v", r.email, e)
|
||||||
|
err = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !emailSent {
|
||||||
|
// only send an error if no email could be sent (to avoid retry of successfully sent emails)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if m.mailGun != nil {
|
||||||
|
_, _, err := m.mailGun.Send(msg.mgMsg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string {
|
||||||
return "SMALLINT"
|
return "SMALLINT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) typeTinyInt() string {
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
return "INTEGER"
|
||||||
|
}
|
||||||
|
return "TINYINT"
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) typeText() string {
|
func (db *datastore) typeText() string {
|
||||||
return "TEXT"
|
return "TEXT"
|
||||||
}
|
}
|
||||||
|
@ -54,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string {
|
||||||
return fmt.Sprintf("VARCHAR(%d)", l)
|
return fmt.Sprintf("VARCHAR(%d)", l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) typeVarBinary(l int) string {
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
return "BLOB"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("VARBINARY(%d)", l)
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) typeBool() string {
|
func (db *datastore) typeBool() string {
|
||||||
if db.driverName == driverSQLite {
|
if db.driverName == driverSQLite {
|
||||||
return "INTEGER"
|
return "INTEGER"
|
||||||
|
@ -65,6 +79,15 @@ func (db *datastore) typeDateTime() string {
|
||||||
return "DATETIME"
|
return "DATETIME"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) typeIntPrimaryKey() string {
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
// From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT
|
||||||
|
// ROWID tables) which is always a 64-bit signed integer."
|
||||||
|
return "INTEGER PRIMARY KEY"
|
||||||
|
}
|
||||||
|
return "INT AUTO_INCREMENT PRIMARY KEY"
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) collateMultiByte() string {
|
func (db *datastore) collateMultiByte() string {
|
||||||
if db.driverName == driverSQLite {
|
if db.driverName == driverSQLite {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -65,7 +65,13 @@ var migrations = []Migration{
|
||||||
New("support oauth attach", oauthAttach), // V6 -> V7
|
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||||
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
|
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
|
||||||
New("support post signatures", supportPostSignatures), // V9 -> V10
|
New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0)
|
||||||
|
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
|
||||||
|
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
|
||||||
|
New("support newsletters", supportLetters), // V12 -> V13
|
||||||
|
New("support password resetting", supportPassReset), // V13 -> V14
|
||||||
|
New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15
|
||||||
|
New("support ActivityPub likes", supportRemoteLikes), // V15 -> V16 (v0.16.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentVer returns the current migration version the application is on
|
// CurrentVer returns the current migration version the application is on
|
||||||
|
|
38
migrations/v11.go
Normal file
38
migrations/v11.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widen `oauth_users.access_token`, necessary only for mysql
|
||||||
|
*/
|
||||||
|
func widenOauthAcceesToken(db *datastore) error {
|
||||||
|
if db.driverName == driverMySQL {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec(`ALTER TABLE oauth_users MODIFY COLUMN access_token ` + db.typeText() + db.collateMultiByte() + ` NULL`)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
33
migrations/v12.go
Normal file
33
migrations/v12.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
func fediverseVerifyProfile(db *datastore) error {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox"))
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
58
migrations/v13.go
Normal file
58
migrations/v13.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2021 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
func supportLetters(db *datastore) error {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec(`CREATE TABLE publishjobs (
|
||||||
|
id ` + db.typeIntPrimaryKey() + `,
|
||||||
|
post_id ` + db.typeVarChar(16) + ` not null,
|
||||||
|
action ` + db.typeVarChar(16) + ` not null,
|
||||||
|
delay ` + db.typeTinyInt() + ` not null
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec(`CREATE TABLE emailsubscribers (
|
||||||
|
id ` + db.typeChar(8) + ` not null,
|
||||||
|
collection_id ` + db.typeInt() + ` not null,
|
||||||
|
user_id ` + db.typeInt() + ` null,
|
||||||
|
email ` + db.typeVarChar(255) + ` null,
|
||||||
|
subscribed ` + db.typeDateTime() + ` not null,
|
||||||
|
token ` + db.typeChar(16) + ` not null,
|
||||||
|
confirmed ` + db.typeBool() + ` default 0 not null,
|
||||||
|
allow_export ` + db.typeBool() + ` default 0 not null,
|
||||||
|
constraint eu_coll_email
|
||||||
|
unique (collection_id, email),
|
||||||
|
constraint eu_coll_user
|
||||||
|
unique (collection_id, user_id),
|
||||||
|
PRIMARY KEY (id)
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
37
migrations/v14.go
Normal file
37
migrations/v14.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
func supportPassReset(db *datastore) error {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec(`CREATE TABLE password_resets (
|
||||||
|
user_id ` + db.typeInt() + ` not null,
|
||||||
|
token ` + db.typeChar(32) + ` not null primary key,
|
||||||
|
used ` + db.typeBool() + ` default 0 not null,
|
||||||
|
created ` + db.typeDateTime() + ` not null
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
33
migrations/v15.go
Normal file
33
migrations/v15.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
func addPostRetrievalIndex(db *datastore) error {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)")
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
38
migrations/v16.go
Normal file
38
migrations/v16.go
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2024 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
func supportRemoteLikes(db *datastore) error {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = t.Exec(`CREATE TABLE remote_likes (
|
||||||
|
post_id ` + db.typeChar(16) + ` NOT NULL,
|
||||||
|
remote_user_id ` + db.typeInt() + ` NOT NULL,
|
||||||
|
created ` + db.typeDateTime() + ` NOT NULL,
|
||||||
|
PRIMARY KEY (post_id,remote_user_id)
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ import (
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
"io/ioutil"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
@ -144,7 +144,7 @@ func verifyReceipt(receipt, id string) error {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to read %s response body: %s", receiptsHost, err)
|
log.Error("Unable to read %s response body: %s", receiptsHost, err)
|
||||||
return err
|
return err
|
||||||
|
|
10
nodeinfo.go
10
nodeinfo.go
|
@ -46,7 +46,7 @@ func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config {
|
||||||
Software: nodeinfo.SoftwareMeta{
|
Software: nodeinfo.SoftwareMeta{
|
||||||
HomePage: softwareURL,
|
HomePage: softwareURL,
|
||||||
GitHub: "https://github.com/writefreely/writefreely",
|
GitHub: "https://github.com/writefreely/writefreely",
|
||||||
Follow: "https://writing.exchange/@write_as",
|
Follow: "https://writing.exchange/@writefreely",
|
||||||
},
|
},
|
||||||
MaxBlogs: cfg.App.MaxBlogs,
|
MaxBlogs: cfg.App.MaxBlogs,
|
||||||
PublicReader: cfg.App.LocalTimeline,
|
PublicReader: cfg.App.LocalTimeline,
|
||||||
|
@ -94,14 +94,20 @@ INNER JOIN collections c
|
||||||
ON collection_id = c.id
|
ON collection_id = c.id
|
||||||
WHERE collection_id IS NOT NULL
|
WHERE collection_id IS NOT NULL
|
||||||
AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear)
|
AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed getting 6-month active user stats: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
|
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||||
SELECT DISTINCT collection_id
|
SELECT DISTINCT collection_id
|
||||||
FROM posts
|
FROM posts
|
||||||
INNER JOIN FROM collections c
|
INNER JOIN collections c
|
||||||
ON collection_id = c.id
|
ON collection_id = c.id
|
||||||
WHERE collection_id IS NOT NULL
|
WHERE collection_id IS NOT NULL
|
||||||
AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth)
|
AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed getting 1-month active user stats: %s", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodeinfo.Usage{
|
return nodeinfo.Usage{
|
||||||
|
|
3
oauth.go
3
oauth.go
|
@ -15,7 +15,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -450,7 +449,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error
|
||||||
|
|
||||||
func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
|
func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
|
||||||
lr := io.LimitReader(body, int64(n+1))
|
lr := io.LimitReader(body, int64(n+1))
|
||||||
data, err := ioutil.ReadAll(lr)
|
data, err := io.ReadAll(lr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -70,6 +70,8 @@ func (c genericOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
|
|
||||||
func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||||
form := url.Values{}
|
form := url.Values{}
|
||||||
|
form.Add("client_id", c.ClientID)
|
||||||
|
form.Add("client_secret", c.ClientSecret)
|
||||||
form.Add("grant_type", "authorization_code")
|
form.Add("grant_type", "authorization_code")
|
||||||
form.Add("redirect_uri", c.CallbackLocation)
|
form.Add("redirect_uri", c.CallbackLocation)
|
||||||
form.Add("scope", c.Scope)
|
form.Add("scope", c.Scope)
|
||||||
|
|
|
@ -13,7 +13,7 @@ package writefreely
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/writeas/slug"
|
"github.com/gosimple/slug"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
10
ossl_legacy.cnf
Normal file
10
ossl_legacy.cnf
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
|
||||||
|
[provider_sect]
|
||||||
|
default = default_sect
|
||||||
|
legacy = legacy_sect
|
||||||
|
|
||||||
|
[default_sect]
|
||||||
|
activate = 1
|
||||||
|
|
||||||
|
[legacy_sect]
|
||||||
|
activate = 1
|
3
pad.go
3
pad.go
|
@ -55,6 +55,9 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
|
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
log.Error("Unable to get user status for Pad: %v", err)
|
log.Error("Unable to get user status for Pad: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ type StaticPage struct {
|
||||||
config.AppCfg
|
config.AppCfg
|
||||||
Version string
|
Version string
|
||||||
HeaderNav bool
|
HeaderNav bool
|
||||||
|
CustomCSS bool
|
||||||
|
|
||||||
// Request values
|
// Request values
|
||||||
Path string
|
Path string
|
||||||
|
|
36
pages.go
36
pages.go
|
@ -40,6 +40,28 @@ func defaultAboutTitle(cfg *config.Config) sql.NullString {
|
||||||
return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true}
|
return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getContactPage(app *App) (*instanceContent, error) {
|
||||||
|
c, err := app.db.GetDynamicContent("contact")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
c = &instanceContent{
|
||||||
|
ID: "contact",
|
||||||
|
Type: "page",
|
||||||
|
Content: defaultContactPage(app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !c.Title.Valid {
|
||||||
|
c.Title = defaultContactTitle()
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultContactTitle() sql.NullString {
|
||||||
|
return sql.NullString{String: "Contact Us", Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
func getPrivacyPage(app *App) (*instanceContent, error) {
|
func getPrivacyPage(app *App) (*instanceContent, error) {
|
||||||
c, err := app.db.GetDynamicContent("privacy")
|
c, err := app.db.GetDynamicContent("privacy")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -70,12 +92,24 @@ func defaultAboutPage(cfg *config.Config) string {
|
||||||
return `_` + cfg.App.SiteName + `_ is a place for you to write and publish, powered by [WriteFreely](https://writefreely.org).`
|
return `_` + cfg.App.SiteName + `_ is a place for you to write and publish, powered by [WriteFreely](https://writefreely.org).`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultContactPage(app *App) string {
|
||||||
|
c, err := app.db.GetCollectionByID(1)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return `_` + app.cfg.App.SiteName + `_ is administered by: [**` + c.Alias + `**](/` + c.Alias + `/).
|
||||||
|
|
||||||
|
Contact them at this email address: _EMAIL GOES HERE_.
|
||||||
|
|
||||||
|
You can also reach them here...`
|
||||||
|
}
|
||||||
|
|
||||||
func defaultPrivacyPolicy(cfg *config.Config) string {
|
func defaultPrivacyPolicy(cfg *config.Config) string {
|
||||||
return `[WriteFreely](https://writefreely.org), the software that powers this site, is built to enforce your right to privacy by default.
|
return `[WriteFreely](https://writefreely.org), the software that powers this site, is built to enforce your right to privacy by default.
|
||||||
|
|
||||||
It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database. We salt and hash your account's password.
|
It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database. We salt and hash your account's password.
|
||||||
|
|
||||||
We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.
|
We store log files, or data about what happens on our servers. We also use cookies to keep you logged into your account.
|
||||||
|
|
||||||
Beyond this, it's important that you trust whoever runs **` + cfg.App.SiteName + `**. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.`
|
Beyond this, it's important that you trust whoever runs **` + cfg.App.SiteName + `**. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.`
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{{define "head"}}<title>Page not found — {{.SiteName}}</title>{{end}}
|
{{define "head"}}<title>Page not found — {{.SiteName}}</title>{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="error-page">
|
<div class="error-page">
|
||||||
<p class="msg">This page is missing.</p>
|
<p class="msg">Page not found.</p>
|
||||||
<p>Are you sure it was ever here?</p>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -2,9 +2,7 @@
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="content-container tight">
|
<div class="content-container tight">
|
||||||
<h1>Server error 😵</h1>
|
<h1>Server error 😵</h1>
|
||||||
<p>Please <a href="https://github.com/writefreely/writefreely/issues/new">contact the human authors</a> of this software and remind them of their many shortcomings.</p>
|
<p>There seems to be an issue with this server. Please <a href="/contact">contact the admin</a> and let them know they'll need to fix it.</p>
|
||||||
<p>Be gentle, though. They are fragile mortal beings.</p>
|
|
||||||
<p style="margin-top:2em">Also, unlike the AI that will soon replace them, you will need to include an error log from the server in your report. (Utterly <em>primitive</em>, we know.)</p>
|
|
||||||
<p>– {{.SiteName}} 🤖</p>
|
<p>– {{.SiteName}} 🤖</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
8
pages/contact.tmpl
Normal file
8
pages/contact.tmpl
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{{define "head"}}<title>{{.ContentTitle}} — {{.SiteName}}</title>
|
||||||
|
<meta name="description" content="{{.PlainContent}}">
|
||||||
|
{{end}}
|
||||||
|
{{define "content"}}<div class="content-container snug">
|
||||||
|
<h1>{{.ContentTitle}}</h1>
|
||||||
|
{{.Content}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
|
@ -1,13 +1,19 @@
|
||||||
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
||||||
<meta name="description" content="Log in to {{.SiteName}}.">
|
<meta name="description" content="Log into {{.SiteName}}.">
|
||||||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
<meta itemprop="description" content="Log into {{.SiteName}}.">
|
||||||
<style>
|
<style>
|
||||||
input{margin-bottom:0.5em;}
|
input{margin-bottom:0.5em;}
|
||||||
|
p.forgot {
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="tight content-container">
|
<div class="tight content-container">
|
||||||
<h1>Log in to {{.SiteName}}</h1>
|
<h1>Log into {{.SiteName}}</h1>
|
||||||
|
|
||||||
{{if .Flashes}}<ul class="errors">
|
{{if .Flashes}}<ul class="errors">
|
||||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
|
@ -19,6 +25,7 @@ input{margin-bottom:0.5em;}
|
||||||
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
||||||
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
|
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
|
||||||
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
||||||
|
{{if .EmailEnabled}}<p class="forgot"><a href="/reset">Forgot password?</a></p>{{end}}
|
||||||
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
|
{{if .To}}<input type="hidden" name="to" value="{{.To}}" />{{end}}
|
||||||
<input type="submit" id="btn-login" value="Login" />
|
<input type="submit" id="btn-login" value="Login" />
|
||||||
</form>
|
</form>
|
||||||
|
|
58
pages/reset.tmpl
Normal file
58
pages/reset.tmpl
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
{{define "head"}}<title>Reset password — {{.SiteName}}</title>
|
||||||
|
<style>
|
||||||
|
input {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="toosmall content-container clean">
|
||||||
|
<h1>Reset your password</h1>
|
||||||
|
|
||||||
|
{{ if .DisablePasswordAuth }}
|
||||||
|
<div class="alert info">
|
||||||
|
<p><strong>Password login is disabled on this server</strong>, so it's not possible to reset your password.</p>
|
||||||
|
</div>
|
||||||
|
{{ else if not .EmailEnabled }}
|
||||||
|
<div class="alert info">
|
||||||
|
<p><strong>Email is not configured on this server!</strong> Please <a href="/contact">contact your admin</a> to reset your password.</p>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
{{if .Flashes}}<ul class="errors">
|
||||||
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
|
</ul>{{end}}
|
||||||
|
|
||||||
|
{{if .IsResetting}}
|
||||||
|
<form method="post" action="/reset" onsubmit="disableSubmit()">
|
||||||
|
<label>
|
||||||
|
<p>New Password</p>
|
||||||
|
<input type="password" name="new-pass" autocomplete="new-password" placeholder="New password" tabindex="1" />
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="t" value="{{.Token}}" />
|
||||||
|
<input type="submit" id="btn-login" value="Reset Password" />
|
||||||
|
{{ .CSRFField }}
|
||||||
|
</form>
|
||||||
|
{{else if not .IsSent}}
|
||||||
|
<form action="/reset" method="post" onsubmit="disableSubmit()">
|
||||||
|
<label>
|
||||||
|
<p>Username</p>
|
||||||
|
<input type="text" name="alias" placeholder="Username" autofocus />
|
||||||
|
</label>
|
||||||
|
{{ .CSRFField }}
|
||||||
|
<input type="submit" id="btn-login" value="Reset Password" />
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var $btn = document.getElementById("btn-login");
|
||||||
|
function disableSubmit() {
|
||||||
|
$btn.disabled = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
|
{{end}}
|
|
@ -80,3 +80,12 @@ func TruncToWord(s string, l int) (string, bool) {
|
||||||
}
|
}
|
||||||
return s, truncated
|
return s, truncated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Truncate truncates the given text to the provided limit, returning the original string if it's shorter than the limit.
|
||||||
|
func Truncate(s string, l int) string {
|
||||||
|
c := []rune(s)
|
||||||
|
if len(c) > l {
|
||||||
|
s = string(c[:l])
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
|
@ -120,7 +120,7 @@ func (p *PublicPost) augmentReadingDestination() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
|
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
|
||||||
return applyMarkdownSpecial(data, false, baseURL, cfg)
|
return applyMarkdownSpecial(data, baseURL, cfg, cfg.App.SingleUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func disableYoutubeAutoplay(outHTML string) string {
|
func disableYoutubeAutoplay(outHTML string) string {
|
||||||
|
@ -142,7 +142,7 @@ func disableYoutubeAutoplay(outHTML string) string {
|
||||||
return outHTML
|
return outHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
|
func applyMarkdownSpecial(data []byte, baseURL string, cfg *config.Config, skipNoFollow bool) string {
|
||||||
mdExtensions := 0 |
|
mdExtensions := 0 |
|
||||||
blackfriday.EXTENSION_TABLES |
|
blackfriday.EXTENSION_TABLES |
|
||||||
blackfriday.EXTENSION_FENCED_CODE |
|
blackfriday.EXTENSION_FENCED_CODE |
|
||||||
|
@ -270,6 +270,7 @@ func getSanitizationPolicy() *bluemonday.Policy {
|
||||||
policy.AllowAttrs("target").OnElements("a")
|
policy.AllowAttrs("target").OnElements("a")
|
||||||
policy.AllowAttrs("title").OnElements("abbr")
|
policy.AllowAttrs("title").OnElements("abbr")
|
||||||
policy.AllowAttrs("style", "class", "id").Globally()
|
policy.AllowAttrs("style", "class", "id").Globally()
|
||||||
|
policy.AllowAttrs("alt").OnElements("img")
|
||||||
policy.AllowElements("header", "footer")
|
policy.AllowElements("header", "footer")
|
||||||
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
|
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
|
||||||
return policy
|
return policy
|
||||||
|
@ -284,12 +285,13 @@ func sanitizePost(content string) string {
|
||||||
// choosing what to generate. In case a post has a title, this function will
|
// choosing what to generate. In case a post has a title, this function will
|
||||||
// fail, and logic should instead be implemented to skip this when there's no
|
// fail, and logic should instead be implemented to skip this when there's no
|
||||||
// title, like so:
|
// title, like so:
|
||||||
// var desc string
|
//
|
||||||
// if title == "" {
|
// var desc string
|
||||||
// desc = postDescription(content, title, friendlyId)
|
// if title == "" {
|
||||||
// } else {
|
// desc = postDescription(content, title, friendlyId)
|
||||||
// desc = shortPostDescription(content)
|
// } else {
|
||||||
// }
|
// desc = shortPostDescription(content)
|
||||||
|
// }
|
||||||
func postDescription(content, title, friendlyId string) string {
|
func postDescription(content, title, friendlyId string) string {
|
||||||
maxLen := 140
|
maxLen := 140
|
||||||
|
|
||||||
|
|
110
posts.go
110
posts.go
|
@ -14,6 +14,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/writefreely/writefreely/spam"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -22,6 +23,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gosimple/slug"
|
||||||
"github.com/guregu/null"
|
"github.com/guregu/null"
|
||||||
"github.com/guregu/null/zero"
|
"github.com/guregu/null/zero"
|
||||||
"github.com/kylemcc/twitter-text-go/extract"
|
"github.com/kylemcc/twitter-text-go/extract"
|
||||||
|
@ -29,7 +31,6 @@ import (
|
||||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/monday"
|
"github.com/writeas/monday"
|
||||||
"github.com/writeas/slug"
|
|
||||||
"github.com/writeas/web-core/activitystreams"
|
"github.com/writeas/web-core/activitystreams"
|
||||||
"github.com/writeas/web-core/bots"
|
"github.com/writeas/web-core/bots"
|
||||||
"github.com/writeas/web-core/converter"
|
"github.com/writeas/web-core/converter"
|
||||||
|
@ -48,6 +49,12 @@ const (
|
||||||
postIDLen = 10
|
postIDLen = 10
|
||||||
|
|
||||||
postMetaDateFormat = "2006-01-02 15:04:05"
|
postMetaDateFormat = "2006-01-02 15:04:05"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PostType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
postArch PostType = "archive"
|
||||||
|
|
||||||
shortCodePaid = "<!--paid-->"
|
shortCodePaid = "<!--paid-->"
|
||||||
)
|
)
|
||||||
|
@ -104,6 +111,7 @@ type (
|
||||||
Created time.Time `db:"created" json:"created"`
|
Created time.Time `db:"created" json:"created"`
|
||||||
Updated time.Time `db:"updated" json:"updated"`
|
Updated time.Time `db:"updated" json:"updated"`
|
||||||
ViewCount int64 `db:"view_count" json:"-"`
|
ViewCount int64 `db:"view_count" json:"-"`
|
||||||
|
LikeCount int64 `db:"like_count" json:"likes"`
|
||||||
Title zero.String `db:"title" json:"title"`
|
Title zero.String `db:"title" json:"title"`
|
||||||
HTMLTitle template.HTML `db:"title" json:"-"`
|
HTMLTitle template.HTML `db:"title" json:"-"`
|
||||||
Content string `db:"content" json:"body"`
|
Content string `db:"content" json:"body"`
|
||||||
|
@ -126,6 +134,7 @@ type (
|
||||||
IsTopLevel bool `json:"-"`
|
IsTopLevel bool `json:"-"`
|
||||||
DisplayDate string `json:"-"`
|
DisplayDate string `json:"-"`
|
||||||
Views int64 `json:"views"`
|
Views int64 `json:"views"`
|
||||||
|
Likes int64 `json:"likes"`
|
||||||
Owner *PublicUser `json:"-"`
|
Owner *PublicUser `json:"-"`
|
||||||
IsOwner bool `json:"-"`
|
IsOwner bool `json:"-"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
@ -139,6 +148,7 @@ type (
|
||||||
IsPinned bool
|
IsPinned bool
|
||||||
IsCustomDomain bool
|
IsCustomDomain bool
|
||||||
Monetization string
|
Monetization string
|
||||||
|
Verification string
|
||||||
PinnedPosts *[]PublicPost
|
PinnedPosts *[]PublicPost
|
||||||
IsFound bool
|
IsFound bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
@ -312,6 +322,8 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
// Display reserved page if that is requested resource
|
// Display reserved page if that is requested resource
|
||||||
if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
|
if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
|
||||||
return handleTemplatedPage(app, w, r, t)
|
return handleTemplatedPage(app, w, r, t)
|
||||||
|
} else if r.URL.Path == "/sitemap.xml" && !app.cfg.App.SingleUser {
|
||||||
|
return impart.HTTPError{Status: http.StatusNotFound, Message: "Page not found."}
|
||||||
} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
|
} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
|
||||||
// Serve static file
|
// Serve static file
|
||||||
app.shttp.ServeHTTP(w, r)
|
app.shttp.ServeHTTP(w, r)
|
||||||
|
@ -339,6 +351,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
var ownerID sql.NullInt64
|
var ownerID sql.NullInt64
|
||||||
|
var collectionID sql.NullInt64
|
||||||
var title string
|
var title string
|
||||||
var content string
|
var content string
|
||||||
var font string
|
var font string
|
||||||
|
@ -354,7 +367,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
|
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl)
|
err := app.db.QueryRow("SELECT owner_id, collection_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?", friendlyID).Scan(&ownerID, &collectionID, &title, &content, &font, &views, &language, &rtl)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
found = false
|
found = false
|
||||||
|
@ -424,6 +437,16 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var protectDraft bool
|
||||||
|
if found && collectionID.Valid {
|
||||||
|
collection, err := app.db.GetCollectionByID(collectionID.Int64)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("view post: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
protectDraft = collection.IsPrivate() || collection.IsProtected()
|
||||||
|
}
|
||||||
|
|
||||||
// Check if post has been unpublished
|
// Check if post has been unpublished
|
||||||
if title == "" && content == "" {
|
if title == "" && content == "" {
|
||||||
gone = true
|
gone = true
|
||||||
|
@ -488,6 +511,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
if !page.IsOwner && silenced {
|
if !page.IsOwner && silenced {
|
||||||
return ErrPostNotFound
|
return ErrPostNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !page.IsOwner && protectDraft {
|
||||||
|
return ErrPostNotFound
|
||||||
|
}
|
||||||
page.Silenced = silenced
|
page.Silenced = silenced
|
||||||
err = templates["post"].ExecuteTemplate(w, "post", page)
|
err = templates["post"].ExecuteTemplate(w, "post", page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -516,9 +543,9 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
// newPost creates a new post with or without an owning Collection.
|
// newPost creates a new post with or without an owning Collection.
|
||||||
//
|
//
|
||||||
// Endpoints:
|
// Endpoints:
|
||||||
// /posts
|
// - /posts
|
||||||
// /posts?collection={alias}
|
// - /posts?collection={alias}
|
||||||
// ? /collections/{alias}/posts
|
// - ? /collections/{alias}/posts
|
||||||
func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
reqJSON := IsJSON(r)
|
reqJSON := IsJSON(r)
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
|
@ -651,8 +678,17 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
// Write success now
|
// Write success now
|
||||||
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
|
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
|
||||||
|
|
||||||
if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
|
if newPost.Collection != nil {
|
||||||
go federatePost(app, newPost, newPost.Collection.ID, false)
|
if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
|
||||||
|
go federatePost(app, newPost, newPost.Collection.ID, false)
|
||||||
|
}
|
||||||
|
if app.cfg.Email.Enabled() && newPost.Collection.EmailSubsEnabled() {
|
||||||
|
go app.db.InsertJob(&PostJob{
|
||||||
|
PostID: newPost.ID,
|
||||||
|
Action: "email",
|
||||||
|
Delay: emailSendDelay,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -952,16 +988,23 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !app.cfg.App.Private && app.cfg.App.Federation {
|
for _, pRes := range *res {
|
||||||
for _, pRes := range *res {
|
if pRes.Code != http.StatusOK {
|
||||||
if pRes.Code != http.StatusOK {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
if !app.cfg.App.Private && app.cfg.App.Federation {
|
||||||
if !pRes.Post.Created.After(time.Now()) {
|
if !pRes.Post.Created.After(time.Now()) {
|
||||||
pRes.Post.Collection.hostName = app.cfg.App.Host
|
pRes.Post.Collection.hostName = app.cfg.App.Host
|
||||||
go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
|
go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if app.cfg.Email.Enabled() && pRes.Post.Collection.EmailSubsEnabled() {
|
||||||
|
go app.db.InsertJob(&PostJob{
|
||||||
|
PostID: pRes.Post.ID,
|
||||||
|
Action: "email",
|
||||||
|
Delay: emailSendDelay,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return impart.WriteSuccess(w, res, http.StatusOK)
|
return impart.WriteSuccess(w, res, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
@ -1067,7 +1110,7 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
ppr := PinPostResult{ID: p.ID}
|
ppr := PinPostResult{ID: p.ID}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ppr.Code = http.StatusInternalServerError
|
ppr.Code = http.StatusInternalServerError
|
||||||
// TODO: set error messsage
|
// TODO: set error message
|
||||||
} else {
|
} else {
|
||||||
ppr.Code = http.StatusOK
|
ppr.Code = http.StatusOK
|
||||||
}
|
}
|
||||||
|
@ -1119,8 +1162,7 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
p.extractData()
|
p.extractData()
|
||||||
|
|
||||||
accept := r.Header.Get("Accept")
|
if IsActivityPubRequest(r) {
|
||||||
if strings.Contains(accept, "application/activity+json") {
|
|
||||||
if coll == nil {
|
if coll == nil {
|
||||||
// This is a draft post; 404 for now
|
// This is a draft post; 404 for now
|
||||||
// TODO: return ActivityObject
|
// TODO: return ActivityObject
|
||||||
|
@ -1150,6 +1192,7 @@ func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
func (p *Post) processPost() PublicPost {
|
func (p *Post) processPost() PublicPost {
|
||||||
res := &PublicPost{Post: p, Views: 0}
|
res := &PublicPost{Post: p, Views: 0}
|
||||||
res.Views = p.ViewCount
|
res.Views = p.ViewCount
|
||||||
|
res.Likes = p.LikeCount
|
||||||
// TODO: move to own function
|
// TODO: move to own function
|
||||||
loc := monday.FuzzyLocale(p.Language.String)
|
loc := monday.FuzzyLocale(p.Language.String)
|
||||||
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
|
res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
|
||||||
|
@ -1164,6 +1207,15 @@ func (p *PublicPost) CanonicalURL(hostName string) string {
|
||||||
return p.Collection.CanonicalURL() + p.Slug.String
|
return p.Collection.CanonicalURL() + p.Slug.String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pp *PublicPost) DisplayCanonicalURL() string {
|
||||||
|
us := pp.CanonicalURL(pp.Collection.hostName)
|
||||||
|
u, err := url.Parse(us)
|
||||||
|
if err != nil {
|
||||||
|
return us
|
||||||
|
}
|
||||||
|
return u.Hostname() + u.Path
|
||||||
|
}
|
||||||
|
|
||||||
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
|
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
|
||||||
cfg := app.cfg
|
cfg := app.cfg
|
||||||
var o *activitystreams.Object
|
var o *activitystreams.Object
|
||||||
|
@ -1464,6 +1516,10 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
|
||||||
// User tried to access blog feed without a trailing slash, and
|
// User tried to access blog feed without a trailing slash, and
|
||||||
// there's no post with a slug "feed"
|
// there's no post with a slug "feed"
|
||||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"}
|
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "feed/"}
|
||||||
|
} else if slug == "archive" {
|
||||||
|
// User tried to access blog Archive without a trailing slash, and
|
||||||
|
// there's no post with a slug "archive"
|
||||||
|
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "archive/"}
|
||||||
}
|
}
|
||||||
|
|
||||||
po := &Post{
|
po := &Post{
|
||||||
|
@ -1520,7 +1576,7 @@ Are you sure it was ever here?`,
|
||||||
fmt.Fprintf(w, "# %s\n\n", p.Title.String)
|
fmt.Fprintf(w, "# %s\n\n", p.Title.String)
|
||||||
}
|
}
|
||||||
fmt.Fprint(w, p.Content)
|
fmt.Fprint(w, p.Content)
|
||||||
} else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
} else if IsActivityPubRequest(r) {
|
||||||
if !postFound {
|
if !postFound {
|
||||||
return ErrCollectionPageNotFound
|
return ErrCollectionPageNotFound
|
||||||
}
|
}
|
||||||
|
@ -1532,6 +1588,15 @@ Are you sure it was ever here?`,
|
||||||
} else {
|
} else {
|
||||||
p.extractData()
|
p.extractData()
|
||||||
p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
|
p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
|
||||||
|
if app.cfg.Email.Enabled() && c.EmailSubsEnabled() {
|
||||||
|
// TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post.
|
||||||
|
if u != nil && u.IsEmailSubscriber(app, c.ID) {
|
||||||
|
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates. <a href="/api/collections/`+c.Alias+`/email/unsubscribe?slug=`+p.Slug.String+`">Unsubscribe</a>.</p>`, -1)
|
||||||
|
} else {
|
||||||
|
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<form method="post" id="emailsub" action="/api/collections/`+c.Alias+`/email/subscribe"><input type="hidden" name="slug" value="`+p.Slug.String+`" /><input type="hidden" name="web" value="1" /><div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="email" name="`+spam.HoneypotFieldName()+`" tabindex="-1" value="" /><input type="password" name="fake_password" tabindex="-1" placeholder="password" autocomplete="new-password" /></div><input type="email" name="email" placeholder="me@example.com" /><input type="submit" id="subscribe-btn" value="Subscribe" /></form>`, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Content = strings.Replace(p.Content, "<!--emailsub-->", "<!--emailsub-->", 1)
|
||||||
// TODO: move this to function
|
// TODO: move this to function
|
||||||
p.formatContent(app.cfg, cr.isCollOwner, true)
|
p.formatContent(app.cfg, cr.isCollOwner, true)
|
||||||
tp := CollectionPostPage{
|
tp := CollectionPostPage{
|
||||||
|
@ -1547,7 +1612,8 @@ Are you sure it was ever here?`,
|
||||||
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
|
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
|
||||||
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
|
tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
|
||||||
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
|
tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
|
||||||
tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
tp.Monetization = coll.Monetization
|
||||||
|
tp.Verification = coll.Verification
|
||||||
|
|
||||||
if !postFound {
|
if !postFound {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
@ -1595,6 +1661,14 @@ func (p *Post) extractData() {
|
||||||
p.extractImages()
|
p.extractImages()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Post) IsSans() bool {
|
||||||
|
return p.Font == "sans"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Post) IsMonospace() bool {
|
||||||
|
return p.Font == "mono"
|
||||||
|
}
|
||||||
|
|
||||||
func (rp *RawPost) UserFacingCreated() string {
|
func (rp *RawPost) UserFacingCreated() string {
|
||||||
return rp.Created.Format(postMetaDateFormat)
|
return rp.Created.Format(postMetaDateFormat)
|
||||||
}
|
}
|
||||||
|
@ -1610,7 +1684,7 @@ func (rp *RawPost) Updated8601() string {
|
||||||
return rp.Updated.Format("2006-01-02T15:04:05Z")
|
return rp.Updated.Format("2006-01-02T15:04:05Z")
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
|
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|avif|avifs|webp|jxl|image)$`)
|
||||||
|
|
||||||
func (p *Post) extractImages() {
|
func (p *Post) extractImages() {
|
||||||
p.Images = extractImages(p.Content)
|
p.Images = extractImages(p.Content)
|
||||||
|
|
|
@ -83,6 +83,14 @@ class ProseMirrorView {
|
||||||
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||||
this.updateState(newState);
|
this.updateState(newState);
|
||||||
},
|
},
|
||||||
|
handleDOMEvents: {
|
||||||
|
drop: (view, event) => {
|
||||||
|
// If a file is dropped externally into the editor, do not insert anything. This will not trigger if an image has been inserted after upload and is dragged and dropped internally to change its position.
|
||||||
|
if (event.dataTransfer.files.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// Editor is focused to the last position. This is a workaround for a bug:
|
// Editor is focused to the last position. This is a workaround for a bug:
|
||||||
// 1. 1 type something in an existing entry
|
// 1. 1 type something in an existing entry
|
||||||
|
|
8
read.go
8
read.go
|
@ -229,11 +229,9 @@ func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page in
|
||||||
TotalPages: ttlPages,
|
TotalPages: ttlPages,
|
||||||
SelTopic: tag,
|
SelTopic: tag,
|
||||||
}
|
}
|
||||||
if app.cfg.App.Chorus {
|
u := getUserSession(app, r)
|
||||||
u := getUserSession(app, r)
|
d.IsAdmin = u != nil && u.IsAdmin()
|
||||||
d.IsAdmin = u != nil && u.IsAdmin()
|
d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
|
||||||
d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
|
|
||||||
}
|
|
||||||
c, err := getReaderSection(app)
|
c, err := getReaderSection(app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -13,6 +13,7 @@ package writefreely
|
||||||
import (
|
import (
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func IsJSON(r *http.Request) bool {
|
func IsJSON(r *http.Request) bool {
|
||||||
|
@ -20,3 +21,9 @@ func IsJSON(r *http.Request) bool {
|
||||||
accept := r.Header.Get("Accept")
|
accept := r.Header.Get("Accept")
|
||||||
return ct == "application/json" || accept == "application/json"
|
return ct == "application/json" || accept == "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsActivityPubRequest(r *http.Request) bool {
|
||||||
|
accept := r.Header.Get("Accept")
|
||||||
|
return strings.Contains(accept, "application/activity+json") ||
|
||||||
|
accept == "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
|
||||||
|
}
|
||||||
|
|
22
routes.go
22
routes.go
|
@ -82,7 +82,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
configureGenericOauth(handler, write, apper.App())
|
configureGenericOauth(handler, write, apper.App())
|
||||||
configureGiteaOauth(handler, write, apper.App())
|
configureGiteaOauth(handler, write, apper.App())
|
||||||
|
|
||||||
// Set up dyamic page handlers
|
// Set up dynamic page handlers
|
||||||
// Handle auth
|
// Handle auth
|
||||||
auth := write.PathPrefix("/api/auth/").Subrouter()
|
auth := write.PathPrefix("/api/auth/").Subrouter()
|
||||||
if apper.App().cfg.App.OpenRegistration {
|
if apper.App().cfg.App.OpenRegistration {
|
||||||
|
@ -99,6 +99,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
|
me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
|
||||||
me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
|
me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
|
||||||
me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
|
me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
|
||||||
|
me.HandleFunc("/c/{collection}/subscribers", handler.User(handleViewSubscribers)).Methods("GET")
|
||||||
me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(handleUserDelete))).Methods("POST")
|
me.Path("/delete").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.User(handleUserDelete))).Methods("POST")
|
||||||
me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
|
me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
|
||||||
me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
|
me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
|
||||||
|
@ -147,6 +148,9 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
|
apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
|
||||||
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
|
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
|
||||||
apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
|
apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
|
||||||
|
apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST")
|
||||||
|
apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE")
|
||||||
|
apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET")
|
||||||
apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
|
apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
|
||||||
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
|
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
|
||||||
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
|
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
|
||||||
|
@ -155,12 +159,12 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
// Handle posts
|
// Handle posts
|
||||||
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
|
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
|
||||||
posts := write.PathPrefix("/api/posts/").Subrouter()
|
posts := write.PathPrefix("/api/posts/").Subrouter()
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET")
|
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT")
|
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE")
|
|
||||||
posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
|
|
||||||
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
|
posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
|
||||||
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
|
posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
|
||||||
|
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.AllReader(fetchPost)).Methods("GET")
|
||||||
|
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(existingPost)).Methods("POST", "PUT")
|
||||||
|
posts.HandleFunc("/{post:[a-zA-Z0-9]+}", handler.All(deletePost)).Methods("DELETE")
|
||||||
|
posts.HandleFunc("/{post:[a-zA-Z0-9]+}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
|
||||||
|
|
||||||
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
|
write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
|
||||||
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
||||||
|
@ -180,6 +184,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
|
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
|
||||||
|
|
||||||
// Handle special pages first
|
// Handle special pages first
|
||||||
|
write.Path("/reset").Handler(csrf.Protect(apper.App().keys.CSRFKey)(handler.Web(viewResetPassword, UserLevelNoneRequired)))
|
||||||
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
|
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
|
||||||
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
|
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
|
||||||
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
|
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
|
||||||
|
@ -216,10 +221,17 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
func RouteCollections(handler *Handler, r *mux.Router) {
|
func RouteCollections(handler *Handler, r *mux.Router) {
|
||||||
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
|
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
|
||||||
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
|
r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
|
||||||
|
r.HandleFunc("/archive/", handler.Web(handleViewCollection, UserLevelReader))
|
||||||
|
r.HandleFunc("/{archive:archive}/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
|
||||||
|
r.HandleFunc("/lang:{lang:[a-z]{2}}", handler.Web(handleViewCollectionLang, UserLevelOptional))
|
||||||
|
r.HandleFunc("/lang:{lang:[a-z]{2}}/page/{page:[0-9]+}", handler.Web(handleViewCollectionLang, UserLevelOptional))
|
||||||
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
|
r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
|
||||||
|
r.HandleFunc("/tag:{tag}/page/{page:[0-9]+}", handler.Web(handleViewCollectionTag, UserLevelReader))
|
||||||
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
|
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
|
||||||
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
|
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
|
||||||
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
|
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
|
||||||
|
r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET")
|
||||||
|
r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET")
|
||||||
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
|
r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
|
||||||
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
|
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
|
||||||
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
|
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
###############################################################################
|
###############################################################################
|
||||||
## writefreely update script ##
|
## writefreely update script ##
|
||||||
## ##
|
## ##
|
||||||
## WARNING: running this script will overwrite any modifed assets or ##
|
## WARNING: running this script will overwrite any modified assets or ##
|
||||||
## template files. If you have any custom changes to these files you ##
|
## template files. If you have any custom changes to these files you ##
|
||||||
## should back them up FIRST. ##
|
## should back them up FIRST. ##
|
||||||
## ##
|
## ##
|
||||||
|
|
13
session.go
13
session.go
|
@ -21,6 +21,10 @@ import (
|
||||||
const (
|
const (
|
||||||
day = 86400
|
day = 86400
|
||||||
sessionLength = 180 * day
|
sessionLength = 180 * day
|
||||||
|
|
||||||
|
userEmailCookieName = "ue"
|
||||||
|
userEmailCookieVal = "email"
|
||||||
|
|
||||||
cookieName = "wfu"
|
cookieName = "wfu"
|
||||||
cookieUserVal = "u"
|
cookieUserVal = "u"
|
||||||
|
|
||||||
|
@ -130,12 +134,13 @@ func saveUserSession(app *App, r *http.Request, w http.ResponseWriter) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func getFullUserSession(app *App, r *http.Request) *User {
|
func getFullUserSession(app *App, r *http.Request) (*User, error) {
|
||||||
u := getUserSession(app, r)
|
u := getUserSession(app, r)
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
u, _ = app.db.GetUserByID(u.ID)
|
var err error
|
||||||
return u
|
u, err = app.db.GetUserByID(u.ID)
|
||||||
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,7 @@ func handleViewSitemap(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
host = c.CanonicalURL()
|
host = c.CanonicalURL()
|
||||||
|
|
||||||
sm := buildSitemap(host, pre)
|
sm := buildSitemap(host, pre)
|
||||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false)
|
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error getting posts: %v", err)
|
log.Error("Error getting posts: %v", err)
|
||||||
return err
|
return err
|
||||||
|
|
43
spam/email.go
Normal file
43
spam/email.go
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020-2021 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package spam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/writeas/web-core/id"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var honeypotField string
|
||||||
|
|
||||||
|
func HoneypotFieldName() string {
|
||||||
|
if honeypotField == "" {
|
||||||
|
honeypotField = id.Generate62RandomString(39)
|
||||||
|
}
|
||||||
|
return honeypotField
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanEmail takes an email address and strips it down to a unique address that can be blocked.
|
||||||
|
func CleanEmail(email string) string {
|
||||||
|
emailParts := strings.Split(strings.ToLower(email), "@")
|
||||||
|
if len(emailParts) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
u := emailParts[0]
|
||||||
|
d := emailParts[1]
|
||||||
|
// Ignore anything after '+'
|
||||||
|
plusIdx := strings.IndexRune(u, '+')
|
||||||
|
if plusIdx > -1 {
|
||||||
|
u = u[:plusIdx]
|
||||||
|
}
|
||||||
|
// Strip dots in email address
|
||||||
|
u = strings.ReplaceAll(u, ".", "")
|
||||||
|
return u + "@" + d
|
||||||
|
}
|
25
spam/ip.go
Normal file
25
spam/ip.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2023 Musing Studio LLC.
|
||||||
|
*
|
||||||
|
* This file is part of WriteFreely.
|
||||||
|
*
|
||||||
|
* WriteFreely is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License, included
|
||||||
|
* in the LICENSE file in this source code package.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package spam
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetIP(r *http.Request) string {
|
||||||
|
h := r.Header.Get("X-Forwarded-For")
|
||||||
|
if h == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ips := strings.Split(h, ",")
|
||||||
|
return strings.TrimSpace(ips[0])
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
# static/js
|
# static/js
|
||||||
|
|
||||||
This directory is for Javascript.
|
This directory is for JavaScript.
|
||||||
|
|
||||||
## Updating libraries
|
## Updating libraries
|
||||||
|
|
||||||
|
|
|
@ -188,21 +188,22 @@ var movePostHTML = function(postID) {
|
||||||
}
|
}
|
||||||
return $tmpl.innerHTML.replace(/POST_ID/g, postID);
|
return $tmpl.innerHTML.replace(/POST_ID/g, postID);
|
||||||
}
|
}
|
||||||
var createPostEl = function(post, owned) {
|
var createPostEl = function(post, owned, singleUser) {
|
||||||
var $post = document.createElement('div');
|
var $post = document.createElement('div');
|
||||||
let p = H.createPost(post.id, "", post.body)
|
let p = H.createPost(post.id, "", post.body)
|
||||||
var title = (post.title || p.title || post.id);
|
var title = (post.title || p.title || post.id);
|
||||||
title = title.replace(/</g, "<");
|
title = title.replace(/</g, "<");
|
||||||
|
var postLink = (singleUser ? '/d/' : '/') + post.id
|
||||||
$post.id = 'post-' + post.id;
|
$post.id = 'post-' + post.id;
|
||||||
$post.className = 'post';
|
$post.className = 'post';
|
||||||
$post.innerHTML = '<h3><a href="/' + post.id + '">' + title + '</a></h3>';
|
$post.innerHTML = '<h3><a href="' + postLink + '">' + title + '</a></h3>';
|
||||||
|
|
||||||
var posted = "";
|
var posted = "";
|
||||||
if (post.created) {
|
if (post.created) {
|
||||||
posted = getFormattedDate(new Date(post.created))
|
posted = getFormattedDate(new Date(post.created))
|
||||||
}
|
}
|
||||||
var hasDraft = H.exists('draft' + post.id);
|
var hasDraft = H.exists('draft' + post.id);
|
||||||
$post.innerHTML += '<h4><date>' + posted + '</date> <a class="action" href="/pad/' + post.id + '">edit' + (hasDraft ? 'ed' : '') + '</a> <a class="delete action" href="/' + post.id + '" onclick="delPost(event, \'' + post.id + '\'' + (owned === true ? ', true' : '') + ')">delete</a> '+movePostHTML(post.id)+'</h4>';
|
$post.innerHTML += '<h4><date>' + posted + '</date> <a class="action" href="' + postLink + '/edit">edit' + (hasDraft ? 'ed' : '') + '</a> <a class="delete action" href="/' + post.id + '" onclick="delPost(event, \'' + post.id + '\'' + (owned === true ? ', true' : '') + ')">delete</a> '+movePostHTML(post.id)+'</h4>';
|
||||||
|
|
||||||
if (post.error) {
|
if (post.error) {
|
||||||
$post.innerHTML += '<p class="error"><strong>Sync error:</strong> ' + post.error + ' <nav><a href="#" onclick="localPosts.dismissError(event, this)">dismiss</a> <a href="#" onclick="localPosts.deletePost(event, this, \''+post.id+'\')">remove post</a></nav></p>';
|
$post.innerHTML += '<p class="error"><strong>Sync error:</strong> ' + post.error + ' <nav><a href="#" onclick="localPosts.dismissError(event, this)">dismiss</a> <a href="#" onclick="localPosts.deletePost(event, this, \''+post.id+'\')">remove post</a></nav></p>';
|
||||||
|
|
23
templates.go
23
templates.go
|
@ -14,7 +14,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -71,14 +70,14 @@ func initTemplate(parentDir, name string) {
|
||||||
filepath.Join(parentDir, templatesDir, "base.tmpl"),
|
filepath.Join(parentDir, templatesDir, "base.tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
|
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
|
||||||
}
|
}
|
||||||
if name == "collection" || name == "collection-tags" || name == "chorus-collection" || name == "read" {
|
if name == "collection" || name == "collection-tags" || name == "collection-archive" || name == "chorus-collection" || name == "read" {
|
||||||
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
|
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
|
||||||
files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
|
files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
|
||||||
}
|
}
|
||||||
if name == "chorus-collection" || name == "chorus-collection-post" {
|
if name == "chorus-collection" || name == "chorus-collection-post" {
|
||||||
files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"))
|
files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"))
|
||||||
}
|
}
|
||||||
if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
|
if name == "collection" || name == "collection-tags" || name == "collection-archive" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
|
||||||
files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
|
files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
|
||||||
}
|
}
|
||||||
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
|
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
|
||||||
|
@ -120,7 +119,7 @@ func initUserPage(parentDir, path, key string) {
|
||||||
// InitTemplates loads all template files from the configured parent dir.
|
// InitTemplates loads all template files from the configured parent dir.
|
||||||
func InitTemplates(cfg *config.Config) error {
|
func InitTemplates(cfg *config.Config) error {
|
||||||
log.Info("Loading templates...")
|
log.Info("Loading templates...")
|
||||||
tmplFiles, err := ioutil.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir))
|
tmplFiles, err := os.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -135,7 +134,10 @@ func InitTemplates(cfg *config.Config) error {
|
||||||
|
|
||||||
log.Info("Loading pages...")
|
log.Info("Loading pages...")
|
||||||
// Initialize all static pages that use the base template
|
// Initialize all static pages that use the base template
|
||||||
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error {
|
err = filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") {
|
if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") {
|
||||||
key := i.Name()
|
key := i.Name()
|
||||||
initPage(cfg.Server.PagesParentDir, path, key)
|
initPage(cfg.Server.PagesParentDir, path, key)
|
||||||
|
@ -143,10 +145,16 @@ func InitTemplates(cfg *config.Config) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
log.Info("Loading user pages...")
|
log.Info("Loading user pages...")
|
||||||
// Initialize all user pages that use base templates
|
// Initialize all user pages that use base templates
|
||||||
filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error {
|
err = filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
|
if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
|
||||||
corePath := path
|
corePath := path
|
||||||
if cfg.Server.TemplatesParentDir != "" {
|
if cfg.Server.TemplatesParentDir != "" {
|
||||||
|
@ -162,6 +170,9 @@ func InitTemplates(cfg *config.Config) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}</title>
|
<title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
<meta name="google" value="notranslate">
|
<meta name="google" value="notranslate">
|
||||||
|
@ -13,7 +14,7 @@
|
||||||
|
|
||||||
<div id="overlay"></div>
|
<div id="overlay"></div>
|
||||||
|
|
||||||
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
|
<textarea dir="auto" id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
|
||||||
|
|
||||||
{{end}}{{.Post.Content}}</textarea>
|
{{end}}{{.Post.Content}}</textarea>
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@
|
||||||
</ul></nav>
|
</ul></nav>
|
||||||
<span id="wc" class="hidden if-room room-4">0 words</span>
|
<span id="wc" class="hidden if-room room-4">0 words</span>
|
||||||
</div>
|
</div>
|
||||||
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
|
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need JavaScript enabled to post.</noscript>
|
||||||
<div id="belt">
|
<div id="belt">
|
||||||
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
|
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
|
||||||
<div class="tool"><button title="Publish your writing" id="publish" style="font-weight: bold">Post</button></div>
|
<div class="tool"><button title="Publish your writing" id="publish" style="font-weight: bold">Post</button></div>
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
{{ template "head" . }}
|
{{ template "head" . }}
|
||||||
<link rel="stylesheet" type="text/css" href="/css/{{.Theme}}.css" />
|
<link rel="stylesheet" type="text/css" href="/css/{{.Theme}}.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
@ -27,6 +28,7 @@
|
||||||
<ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
|
<ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
|
||||||
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
|
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
|
||||||
<li><a href="/me/settings">Account settings</a></li>
|
<li><a href="/me/settings">Account settings</a></li>
|
||||||
|
<li><a href="/me/import">Import posts</a></li>
|
||||||
<li><a href="/me/export">Export</a></li>
|
<li><a href="/me/export">Export</a></li>
|
||||||
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
|
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
|
||||||
<li class="separator"><hr /></li>
|
<li class="separator"><hr /></li>
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
|
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="canonical" href="{{.CanonicalURL .Host}}" />
|
<link rel="canonical" href="{{.CanonicalURL .Host}}" />
|
||||||
|
|
|
@ -6,10 +6,11 @@
|
||||||
<title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title>
|
<title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||||
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
|
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
|
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||||
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}}
|
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
@ -91,11 +92,11 @@ body#collection header nav.tabs a:first-child {
|
||||||
|
|
||||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||||
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}}
|
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>{{end}}
|
</nav>{{end}}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
<link rel="stylesheet" type="text/css" href="/css/prose.css" />
|
<link rel="stylesheet" type="text/css" href="/css/prose.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
<meta name="google" value="notranslate">
|
<meta name="google" value="notranslate">
|
||||||
|
@ -61,7 +62,7 @@
|
||||||
</ul></nav>
|
</ul></nav>
|
||||||
<span id="wc" class="hidden if-room room-4">0 words</span>
|
<span id="wc" class="hidden if-room room-4">0 words</span>
|
||||||
</div>
|
</div>
|
||||||
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
|
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need JavaScript enabled to post.</noscript>
|
||||||
<div id="belt">
|
<div id="belt">
|
||||||
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
|
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
|
||||||
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
|
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
|
||||||
|
|
118
templates/collection-archive.tmpl
Normal file
118
templates/collection-archive.tmpl
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
{{define "collection"}}<!DOCTYPE HTML>
|
||||||
|
<html>
|
||||||
|
<head prefix="og: http://ogp.me/ns# article: http://ogp.me/ns/article#">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
|
||||||
|
<title>Archive — {{.Collection.DisplayTitle}}</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||||
|
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||||
|
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||||
|
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}}
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<meta name="generator" content="WriteFreely">
|
||||||
|
<meta name="description" content="{{.PlainDescription}}">
|
||||||
|
<meta itemprop="name" content="{{.DisplayTitle}}">
|
||||||
|
<meta itemprop="description" content="{{.PlainDescription}}">
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="{{.DisplayTitle}}">
|
||||||
|
<meta name="twitter:image" content="{{.AvatarURL}}">
|
||||||
|
<meta name="twitter:description" content="{{.PlainDescription}}">
|
||||||
|
<meta property="og:title" content="{{.DisplayTitle}}" />
|
||||||
|
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content="{{.CanonicalURL}}" />
|
||||||
|
<meta property="og:description" content="{{.PlainDescription}}" />
|
||||||
|
<meta property="og:image" content="{{.AvatarURL}}">
|
||||||
|
{{template "collection-meta" .}}
|
||||||
|
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body id="subpage">
|
||||||
|
|
||||||
|
<div id="overlay"></div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1 dir="{{.Direction}}" id="blog-title"><a href="{{if .IsTopLevel}}/{{else}}/{{.Collection.Alias}}/{{end}}" class="h-card p-author">{{.Collection.DisplayTitle}}</a></h1>
|
||||||
|
<nav>
|
||||||
|
{{if .PinnedPosts}}
|
||||||
|
{{range .PinnedPosts}}<a class="pinned" href="{{if $.IsOwner}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL}}{{end}}">{{.DisplayTitle}}</a>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{if .Posts -}}
|
||||||
|
<section id="wrapper" class="archive" itemscope itemtype="http://schema.org/Blog">
|
||||||
|
{{- else -}}
|
||||||
|
<div id="wrapper" class="archive">
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
<h1>Archive</h1>
|
||||||
|
|
||||||
|
{{if .Flash}}
|
||||||
|
<div class="alert success flash">
|
||||||
|
<p>{{.Flash}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{{ $curYear := 0 }}
|
||||||
|
{{ range $el := .Posts }}
|
||||||
|
{{if ne $curYear .Created.Year}}<li class="year">{{.Created.Year}}</li>{{ $curYear = .Created.Year }}{{end}}
|
||||||
|
<li>
|
||||||
|
{{if .HasTitleLink -}}
|
||||||
|
{{.HTMLTitleArrow}}
|
||||||
|
{{- else -}}
|
||||||
|
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url" class="u-url">
|
||||||
|
{{- if .DisplayTitle -}}
|
||||||
|
{{- .DisplayTitle -}}
|
||||||
|
{{- else -}}
|
||||||
|
(Untitled)
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
{{- end}}
|
||||||
|
{{if .IsScheduled}}[Scheduled]{{end}}
|
||||||
|
{{if $.Format.ShowDates -}}
|
||||||
|
<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">
|
||||||
|
{{- if .HasTitleLink -}}
|
||||||
|
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url">
|
||||||
|
{{- end -}}
|
||||||
|
{{.DisplayDate}}
|
||||||
|
{{- if .HasTitleLink -}}
|
||||||
|
{{- if .IsPaid}}{{template "paid-badge" (dict "CDNHost" $.CDNHost)}}{{end -}}</a>
|
||||||
|
{{- end -}}
|
||||||
|
</time>
|
||||||
|
{{- else -}}
|
||||||
|
{{- if .HasTitleLink -}}
|
||||||
|
<a href="{{if $.SingleUser}}/{{else}}/{{$.Alias}}/{{end}}{{.Slug.String}}" itemprop="url">view</a>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end}}
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{{template "paging" .}}
|
||||||
|
|
||||||
|
{{if .Posts}}</section>{{else}}</div>{{end}}
|
||||||
|
|
||||||
|
{{if .ShowFooterBranding }}
|
||||||
|
<footer>
|
||||||
|
<hr />
|
||||||
|
<nav dir="ltr">
|
||||||
|
{{if not .SingleUser}}<a class="home pubd" href="/">{{.SiteName}}</a> · {{end}}powered by <a style="margin-left:0" href="https://writefreely.org">writefreely</a>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
{{if .CanShowScript}}
|
||||||
|
{{range .ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
||||||
|
{{if .Collection.Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
|
||||||
|
{{end}}
|
||||||
|
<script src="/js/localdate.js"></script>
|
||||||
|
</html>{{end}}
|
|
@ -4,8 +4,9 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
|
|
||||||
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
|
<title>{{.PlainDisplayTitle}} {{localhtml "title dash" .Language.String}} {{.Collection.DisplayTitle}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
{{ if .IsFound }}
|
{{ if .IsFound }}
|
||||||
|
@ -44,7 +45,7 @@
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body id="post">
|
<body id="post">
|
||||||
|
|
||||||
<div id="overlay"></div>
|
<div id="overlay"></div>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
|
@ -54,12 +55,13 @@
|
||||||
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
|
{{range .PinnedPosts}}<a class="pinned{{if eq .Slug.String $.Slug.String}} selected{{end}}" href="{{if not $.SingleUser}}/{{$.Collection.Alias}}/{{.Slug.String}}{{else}}{{.CanonicalURL $.Host}}{{end}}">{{.PlainDisplayTitle}}</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
|
{{ if and .IsOwner .IsFound }}<span class="views" dir="ltr"><strong>{{largeNumFmt .Views}}</strong> {{pluralize "view" "views" .Views}}</span>
|
||||||
<a class="xtra-feature" href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
|
{{if .Likes}}<span class="views" dir="ltr"><strong>{{largeNumFmt .Likes}}</strong> {{pluralize "like" "likes" .Likes}}</span>{{end}}
|
||||||
|
<a href="/{{if not .SingleUser}}{{.Collection.Alias}}/{{end}}{{.Slug.String}}/edit" dir="{{.Direction}}">Edit</a>
|
||||||
{{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
|
{{if .IsPinned}}<a class="xtra-feature unpin" href="/{{.Collection.Alias}}/{{.Slug.String}}/unpin" dir="{{.Direction}}" onclick="unpinPost(event, '{{.ID}}')">Unpin</a>{{end}}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{{if .Silenced}}
|
{{if .Silenced}}
|
||||||
{{template "user-silenced"}}
|
{{template "user-silenced"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@ -69,7 +71,7 @@
|
||||||
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer>
|
<footer dir="ltr"><hr><nav><p style="font-size: 0.9em">{{localhtml "published with write.as" .Language.String}}</p></nav></footer>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
{{if .Collection.CanShowScript}}
|
{{if .Collection.CanShowScript}}
|
||||||
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
||||||
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
|
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
<title>{{.Tag}} — {{.Collection.DisplayTitle}}</title>
|
<title>{{.Tag}} — {{.Collection.DisplayTitle}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
{{if not .Collection.IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.Tag}} posts on {{.DisplayTitle}}" href="{{.CanonicalURL}}tag:{{.Tag}}/feed/" />{{end}}
|
{{if not .Collection.IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.Tag}} posts on {{.DisplayTitle}}" href="{{.CanonicalURL}}tag:{{.Tag}}/feed/" />{{end}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
@ -60,6 +61,17 @@
|
||||||
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
|
{{if .Posts}}<section id="wrapper" itemscope itemtype="http://schema.org/Blog">{{else}}<div id="wrapper">{{end}}
|
||||||
<h1>{{.Tag}}</h1>
|
<h1>{{.Tag}}</h1>
|
||||||
{{template "posts" .}}
|
{{template "posts" .}}
|
||||||
|
|
||||||
|
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||||
|
{{if or (and .Format.Ascending (lt .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||||
|
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||||
|
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||||
|
{{else}}
|
||||||
|
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||||
|
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</nav>{{end}}
|
||||||
|
|
||||||
{{if .Posts}}</section>{{else}}</div>{{end}}
|
{{if .Posts}}</section>{{else}}</div>{{end}}
|
||||||
|
|
||||||
{{ if .Collection.ShowFooterBranding }}
|
{{ if .Collection.ShowFooterBranding }}
|
||||||
|
|
|
@ -6,26 +6,27 @@
|
||||||
<title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title>
|
<title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||||
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
|
{{if gt .CurrentPage 1}}<link rel="prev" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{end}}
|
{{if lt .CurrentPage .TotalPages}}<link rel="next" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{end}}
|
||||||
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}}
|
{{if not .IsPrivate}}<link rel="alternate" type="application/rss+xml" title="{{.DisplayTitle}} » Feed" href="{{.CanonicalURL}}feed/" />{{end}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
<meta name="generator" content="WriteFreely">
|
<meta name="generator" content="WriteFreely">
|
||||||
<meta name="description" content="{{.Description}}">
|
<meta name="description" content="{{.PlainDescription}}">
|
||||||
<meta itemprop="name" content="{{.DisplayTitle}}">
|
<meta itemprop="name" content="{{.DisplayTitle}}">
|
||||||
<meta itemprop="description" content="{{.Description}}">
|
<meta itemprop="description" content="{{.PlainDescription}}">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary">
|
||||||
<meta name="twitter:title" content="{{.DisplayTitle}}">
|
<meta name="twitter:title" content="{{.DisplayTitle}}">
|
||||||
<meta name="twitter:image" content="{{.AvatarURL}}">
|
<meta name="twitter:image" content="{{.AvatarURL}}">
|
||||||
<meta name="twitter:description" content="{{.Description}}">
|
<meta name="twitter:description" content="{{.PlainDescription}}">
|
||||||
<meta property="og:title" content="{{.DisplayTitle}}" />
|
<meta property="og:title" content="{{.DisplayTitle}}" />
|
||||||
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
<meta property="og:site_name" content="{{.DisplayTitle}}" />
|
||||||
<meta property="og:type" content="article" />
|
<meta property="og:type" content="article" />
|
||||||
<meta property="og:url" content="{{.CanonicalURL}}" />
|
<meta property="og:url" content="{{.CanonicalURL}}" />
|
||||||
<meta property="og:description" content="{{.Description}}" />
|
<meta property="og:description" content="{{.PlainDescription}}" />
|
||||||
<meta property="og:image" content="{{.AvatarURL}}">
|
<meta property="og:image" content="{{.AvatarURL}}">
|
||||||
{{template "collection-meta" .}}
|
{{template "collection-meta" .}}
|
||||||
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
|
{{if .StyleSheet}}<style type="text/css">{{.StyleSheetDisplay}}</style>{{end}}
|
||||||
|
@ -53,6 +54,7 @@
|
||||||
{{if .SimpleNav}}<li><a href="/new#{{.Alias}}">New Post</a></li>{{end}}
|
{{if .SimpleNav}}<li><a href="/new#{{.Alias}}">New Post</a></li>{{end}}
|
||||||
<li><a href="/me/c/{{.Alias}}">Customize</a></li>
|
<li><a href="/me/c/{{.Alias}}">Customize</a></li>
|
||||||
<li><a href="/me/c/{{.Alias}}/stats">Stats</a></li>
|
<li><a href="/me/c/{{.Alias}}/stats">Stats</a></li>
|
||||||
|
<li><a href="/me/c/{{.Alias}}/subscribers">Subscribers</a></li>
|
||||||
<li class="separator"><hr /></li>
|
<li class="separator"><hr /></li>
|
||||||
{{if not .SingleUser}}<li><a href="/me/c/"><img class="ic-18dp" src="/img/ic_blogs_dark@2x.png" /> View Blogs</a></li>{{end}}
|
{{if not .SingleUser}}<li><a href="/me/c/"><img class="ic-18dp" src="/img/ic_blogs_dark@2x.png" /> View Blogs</a></li>{{end}}
|
||||||
<li><a href="/me/posts/"><img class="ic-18dp" src="/img/ic_list_dark@2x.png" /> View Drafts</a></li>
|
<li><a href="/me/posts/"><img class="ic-18dp" src="/img/ic_list_dark@2x.png" /> View Drafts</a></li>
|
||||||
|
@ -82,7 +84,7 @@
|
||||||
{{template "user-silenced"}}
|
{{template "user-silenced"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<h1 dir="{{.Direction}}" id="blog-title">{{if .Posts}}{{else}}<span class="writeas-prefix"><a href="/">write.as</a></span> {{end}}<a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
|
<h1 dir="{{.Direction}}" id="blog-title">{{if .Posts}}{{else}}<span class="writeas-prefix"><a href="/">write.as</a></span> {{end}}<a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
|
||||||
{{if .Description}}<p class="description p-note">{{.Description}}</p>{{end}}
|
{{if .Description}}<p class="description p-note">{{.DisplayDescription}}</p>{{end}}
|
||||||
{{/*if not .Public/*}}
|
{{/*if not .Public/*}}
|
||||||
<!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p-->
|
<!--p class="meta-note"><span>Private collection</span>. Only you can see this page.</p-->
|
||||||
{{/*end*/}}
|
{{/*end*/}}
|
||||||
|
@ -102,18 +104,26 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Flash}}
|
||||||
|
<div class="alert success flash">
|
||||||
|
<p>{{.Flash}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{template "posts" .}}
|
{{template "posts" .}}
|
||||||
|
|
||||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||||
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||||
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}}
|
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>{{end}}
|
</nav>{{end}}
|
||||||
|
|
||||||
|
{{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}}
|
||||||
|
|
||||||
{{if .Posts}}</section>{{else}}</div>{{end}}
|
{{if .Posts}}</section>{{else}}</div>{{end}}
|
||||||
|
|
||||||
{{if .ShowFooterBranding }}
|
{{if .ShowFooterBranding }}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<title>Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} — {{.SiteName}}</title>
|
<title>Edit metadata: {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}} — {{.SiteName}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
dt {
|
dt {
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
<a href="/about">about</a>
|
<a href="/about">about</a>
|
||||||
{{if and .LocalTimeline .CanViewReader}}<a href="/read">reader</a>{{end}}
|
{{if and .LocalTimeline .CanViewReader}}<a href="/read">reader</a>{{end}}
|
||||||
{{if .Username}}<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>{{end}}
|
{{if .Username}}<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>{{end}}
|
||||||
|
<a href="/contact">contact</a>
|
||||||
<a href="/privacy">privacy</a>
|
<a href="/privacy">privacy</a>
|
||||||
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
|
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
@ -25,6 +26,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/about">about</a></li>
|
<li><a href="/about">about</a></li>
|
||||||
{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read">reader</a>{{end}}
|
{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read">reader</a>{{end}}
|
||||||
|
<li><a href="/contact">contact</a></li>
|
||||||
<li><a href="/privacy">privacy</a></li>
|
<li><a href="/privacy">privacy</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
<!-- Miscelaneous render related template parts we use multiple times -->
|
<!-- Miscellaneous render related template parts we use multiple times -->
|
||||||
{{define "collection-meta"}}
|
{{define "collection-meta"}}
|
||||||
{{if .Monetization -}}
|
{{if .Monetization -}}
|
||||||
<meta name="monetization" content="{{.DisplayMonetization}}" />
|
<meta name="monetization" content="{{.DisplayMonetization}}" />
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
{{if .Verification -}}
|
||||||
|
<link rel="me" href="{{.Verification}}" />
|
||||||
|
{{- end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "highlighting"}}
|
{{define "highlighting"}}
|
||||||
|
@ -76,7 +79,7 @@
|
||||||
jss.push(lurl);
|
jss.push(lurl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Load files in order, higlight on last load
|
// Load files in order, highlight on last load
|
||||||
loadLanguages(jss, () => {highlight(lb)});
|
loadLanguages(jss, () => {highlight(lb)});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -102,3 +105,28 @@
|
||||||
<script type="text/javascript" id="MathJax-script" src="/js/mathjax/tex-svg-full.js" async>
|
<script type="text/javascript" id="MathJax-script" src="/js/mathjax/tex-svg-full.js" async>
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "emailsubscribe"}}
|
||||||
|
{{if .EmailSubsEnabled}}
|
||||||
|
<div id="emailsub">
|
||||||
|
{{if .IsSubscriber}}
|
||||||
|
<p>You're subscribed to email updates. <a href="/api/collections/{{.Alias}}/email/unsubscribe">Unsubscribe</a>.</p>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/api/collections/{{.Alias}}/email/subscribe">
|
||||||
|
<input type="hidden" name="web" value="1" />
|
||||||
|
<p>Enter your email to subscribe to updates.</p> <div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="email" name="{{.Honeypot}}" tabindex="-1" value="" /><input type="password" name="fake_password" tabindex="-1" placeholder="password" autocomplete="new-password" /></div>
|
||||||
|
<input type="email" name="email" placeholder="me@example.com" />
|
||||||
|
<input type="submit" id="subscribe-btn" value="Subscribe" />
|
||||||
|
</form>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var $form = document.getElementById('emailsub').getElementsByTagName('form')[0];
|
||||||
|
$form.onsubmit = function() {
|
||||||
|
var $sub = document.getElementById('subscribe-btn');
|
||||||
|
$sub.disabled = true;
|
||||||
|
$sub.value = 'Subscribing...';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
|
@ -61,4 +61,16 @@
|
||||||
<a class="read-more" href="{{$.CanonicalURL}}{{.Slug.String}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book e-content">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}{{.HTMLContent}}</div>{{end}}</article>{{ end }}
|
<a class="read-more" href="{{$.CanonicalURL}}{{.Slug.String}}">{{localstr "Read more..." .Language.String}}</a>{{else}}<div {{if .Language}}lang="{{.Language.String}}"{{end}} dir="{{.Direction}}" class="book e-content">{{if and (and (not $.IsOwner) (not $.Format.ShowDates)) (not .Title.String)}}<a class="hidden action" href="{{if $.IsOwner}}/{{$.Alias}}/{{.Slug.String}}{{else}}{{$.CanonicalURL}}{{.Slug.String}}{{end}}">view</a>{{end}}{{.HTMLContent}}</div>{{end}}</article>{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}
|
{{define "paid-badge"}}<img class="paid" alt="Paid article" src="/img/paidarticle.svg" /> {{end}}
|
||||||
|
|
||||||
|
{{define "paging"}}
|
||||||
|
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||||
|
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||||
|
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||||
|
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||||
|
{{else}}
|
||||||
|
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||||
|
{{if gt .CurrentPage 1}}<a style="float:right;" href="{{.PrevPageURL .Prefix .NavSuffix .CurrentPage .IsTopLevel}}">Newer ⇢</a>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</nav>{{end}}
|
||||||
|
{{end}}
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
<title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}</title>
|
<title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} — {{.SiteName}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
<meta name="google" value="notranslate">
|
<meta name="google" value="notranslate">
|
||||||
|
@ -13,7 +14,7 @@
|
||||||
|
|
||||||
<div id="overlay"></div>
|
<div id="overlay"></div>
|
||||||
|
|
||||||
<textarea id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
|
<textarea dir="auto" id="writer" placeholder="Write..." class="{{.Post.Font}}" autofocus>{{if .Post.Title}}# {{.Post.Title}}
|
||||||
|
|
||||||
{{end}}{{.Post.Content}}</textarea>
|
{{end}}{{.Post.Content}}</textarea>
|
||||||
|
|
||||||
|
@ -56,7 +57,7 @@
|
||||||
</ul></nav>
|
</ul></nav>
|
||||||
<span id="wc" class="hidden if-room room-4">0 words</span>
|
<span id="wc" class="hidden if-room room-4">0 words</span>
|
||||||
</div>
|
</div>
|
||||||
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
|
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need JavaScript enabled to post.</noscript>
|
||||||
<div id="belt">
|
<div id="belt">
|
||||||
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
|
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
|
||||||
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
|
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
<title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title>
|
<title>{{.DisplayTitle}}{{if not .SingleUser}} — {{.SiteName}}{{end}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="canonical" href="{{.CanonicalURL}}">
|
<link rel="canonical" href="{{.CanonicalURL}}">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
<link rel="stylesheet" href="/css/lib/mono-blue.min.css">
|
<link rel="stylesheet" href="/css/lib/mono-blue.min.css">
|
||||||
{{end}}
|
{{end}}
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="canonical" href="{{.Host}}/{{if .SingleUser}}d/{{end}}{{.ID}}" />
|
<link rel="canonical" href="{{.Host}}/{{if .SingleUser}}d/{{end}}{{.ID}}" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
@ -18,7 +19,7 @@
|
||||||
<meta name="description" content="{{.Description}}">
|
<meta name="description" content="{{.Description}}">
|
||||||
<meta itemprop="name" content="{{.SiteName}}">
|
<meta itemprop="name" content="{{.SiteName}}">
|
||||||
<meta itemprop="description" content="{{.Description}}">
|
<meta itemprop="description" content="{{.Description}}">
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="{{if gt (len .Images) 0}}summary_large_image{{else}}summary{{end}}">
|
||||||
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}}">
|
<meta name="twitter:title" content="{{if .Title}}{{.Title}}{{else}}{{.GenTitle}}{{end}}">
|
||||||
<meta name="twitter:description" content="{{.Description}}">
|
<meta name="twitter:description" content="{{.Description}}">
|
||||||
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
|
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<div class="snug content-container">
|
<div class="snug content-container">
|
||||||
{{template "admin-header" .}}
|
{{template "admin-header" .}}
|
||||||
|
|
||||||
<!-- TODO: if other use for flashes use patern like account_import.go -->
|
<!-- TODO: if other use for flashes use pattern like account_import.go -->
|
||||||
{{if .Flashes}}
|
{{if .Flashes}}
|
||||||
<p class="alert success">
|
<p class="alert success">
|
||||||
{{range .Flashes}}{{.}}{{end}}
|
{{range .Flashes}}{{.}}{{end}}
|
||||||
|
|
|
@ -29,6 +29,8 @@ input[type=text] {
|
||||||
|
|
||||||
{{if eq .Content.ID "about"}}
|
{{if eq .Content.ID "about"}}
|
||||||
<p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p>
|
<p class="page-desc content-desc">Describe what your instance is <a href="/about" target="page">about</a>.</p>
|
||||||
|
{{else if eq .Content.ID "contact"}}
|
||||||
|
<p class="page-desc content-desc">Tell your users and outside visitors how to <a href="/contact" target="page">contact</a> you.</p>
|
||||||
{{else if eq .Content.ID "privacy"}}
|
{{else if eq .Content.ID "privacy"}}
|
||||||
<p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p>
|
<p class="page-desc content-desc">Outline your <a href="/privacy" target="page">privacy policy</a>.</p>
|
||||||
{{else if eq .Content.ID "reader"}}
|
{{else if eq .Content.ID "reader"}}
|
||||||
|
|
|
@ -45,7 +45,7 @@ input.copy-text {
|
||||||
{{if .NewPassword}}<div class="alert success">
|
{{if .NewPassword}}<div class="alert success">
|
||||||
<p>This user's password has been reset to:</p>
|
<p>This user's password has been reset to:</p>
|
||||||
<p><input type="text" class="copy-text" value="{{.NewPassword}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly /></p>
|
<p><input type="text" class="copy-text" value="{{.NewPassword}}" onfocus="if (this.select) this.select(); else this.setSelectionRange(0, this.value.length);" readonly /></p>
|
||||||
<p>They can use this new password to log in to their account. <strong>This will only be shown once</strong>, so be sure to copy it and send it to them now.</p>
|
<p>They can use this new password to log into their account. <strong>This will only be shown once</strong>, so be sure to copy it and send it to them now.</p>
|
||||||
{{if .ClearEmail}}<p>Their email address is: <a href="mailto:{{.ClearEmail}}">{{.ClearEmail}}</a></p>{{end}}
|
{{if .ClearEmail}}<p>Their email address is: <a href="mailto:{{.ClearEmail}}">{{.ClearEmail}}</a></p>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -202,8 +202,9 @@ function loadMorePosts() {
|
||||||
if (http.readyState == 4) {
|
if (http.readyState == 4) {
|
||||||
if (http.status == 200) {
|
if (http.status == 200) {
|
||||||
var data = JSON.parse(http.responseText);
|
var data = JSON.parse(http.responseText);
|
||||||
|
var singleUser = {{ .SingleUser }};
|
||||||
for (var i=0; i<data.data.length; i++) {
|
for (var i=0; i<data.data.length; i++) {
|
||||||
$posts.el.appendChild(createPostEl(data.data[i], true));
|
$posts.el.appendChild(createPostEl(data.data[i], true, singleUser));
|
||||||
}
|
}
|
||||||
if (data.data.length < 10) {
|
if (data.data.length < 10) {
|
||||||
$loadMore.el.parentNode.removeChild($loadMore.el);
|
$loadMore.el.parentNode.removeChild($loadMore.el);
|
||||||
|
|
|
@ -36,8 +36,8 @@ textarea.section.norm {
|
||||||
<form name="customize-form" action="/api/collections/{{.Alias}}" method="post" onsubmit="return disableSubmit()">
|
<form name="customize-form" action="/api/collections/{{.Alias}}" method="post" onsubmit="return disableSubmit()">
|
||||||
<div id="collection-options">
|
<div id="collection-options">
|
||||||
<div style="text-align:center">
|
<div style="text-align:center">
|
||||||
<h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder="Title" /></h1>
|
<h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder="Title" maxlength="255" /></h1>
|
||||||
<p><input type="text" name="description" id="description" value="{{.Description}}" placeholder="Description" /></p>
|
<p><input type="text" name="description" id="description" value="{{.Description}}" placeholder="Description" maxlength="160" /></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="option">
|
<div class="option">
|
||||||
|
@ -90,6 +90,44 @@ textarea.section.norm {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="option">
|
||||||
|
<h2 id="updates">Updates</h2>
|
||||||
|
<div class="section">
|
||||||
|
<p class="explain">Keep readers updated with your latest posts wherever they are.</p>
|
||||||
|
<ul style="list-style:none">
|
||||||
|
<li>
|
||||||
|
<label class="option-text"><input type="checkbox" checked="checked" disabled />
|
||||||
|
RSS feed
|
||||||
|
</label>
|
||||||
|
<p class="describe">Readers can subscribe to your blog's <a href="{{.CanonicalURL}}feed/" target="feed">RSS feed</a> with their favorite RSS reader.</p>
|
||||||
|
</li>
|
||||||
|
{{if .EmailCfg.Enabled}}
|
||||||
|
<li>
|
||||||
|
<label class="option-text" id="email-sub-label"><input type="checkbox" name="email_subs" id="email_subs" {{if .EmailSubsEnabled}}checked="checked"{{end}} />
|
||||||
|
Email subscriptions
|
||||||
|
</label>
|
||||||
|
<p class="describe">
|
||||||
|
Let readers subscribe to your blog via email, and optionally accept private replies.
|
||||||
|
</p>
|
||||||
|
<div id="custom-letter-reply" style="font-size: .8em; margin-top: -0.5em; margin-left: 1.8em; margin-bottom: 1em;" {{if not .EmailSubsEnabled}}style="display:none"{{end}}>
|
||||||
|
Allow replies to this address:
|
||||||
|
<input type="email" name="letter_reply" id="letter_reply" placeholder="me@example.com" value="{{.LetterReplyTo}}" {{if not .EmailSubsEnabled}}disabled{{end}} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
{{if .Federation}}
|
||||||
|
<li>
|
||||||
|
<label class="option-text" id="federate-label"><input type="checkbox" name="federate" id="federate" {{if .Federation}}checked="checked"{{end}} disabled />
|
||||||
|
Federation
|
||||||
|
</label>
|
||||||
|
<strong id="normal-handle-env" class="fedi-handle">@<span id="fedi-handle">{{.Alias}}</span>@<span id="fedi-domain">{{.FriendlyHost}}</span></strong>
|
||||||
|
<p class="describe">Allow others to follow your blog and interact with your posts in the fediverse. <a href="https://video.writeas.org/videos/watch/cc55e615-d204-417c-9575-7b57674cc6f3" target="video">See how it works</a>.</p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="option">
|
<div class="option">
|
||||||
<h2>Display Format</h2>
|
<h2>Display Format</h2>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
@ -153,11 +191,20 @@ textarea.section.norm {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="option">
|
||||||
|
<h2>Verification</h2>
|
||||||
|
<div class="section">
|
||||||
|
<p class="explain">Verify that you own another site on the open web, fediverse, etc. For example, enter your Mastodon profile address here, then on Mastodon add a link back to this blog — it will show up as <a href="https://joinmastodon.org/verification" target="mastoverified">verified</a> there.</p>
|
||||||
|
<input type="text" name="verification_link" style="width:100%" value="{{.Verification}}" placeholder="https://writing.exchange/@writefreely" />
|
||||||
|
<p class="explain">This adds a <code>rel="me"</code> code in your blog's <code><head></code>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .UserPage.StaticPage.AppCfg.Monetization}}
|
{{if .UserPage.StaticPage.AppCfg.Monetization}}
|
||||||
<div class="option">
|
<div class="option">
|
||||||
<h2>Web Monetization</h2>
|
<h2>Web Monetization</h2>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<p class="explain">Web Monetization enables you to receive micropayments from readers that have a <a href="https://coil.com">Coil membership</a>. Add your payment pointer to enable Web Monetization on your blog.</p>
|
<p class="explain">Web Monetization enables you to receive micropayments from readers via <a href="https://interledger.org">Interledger</a>. Add your payment pointer to enable Web Monetization on your blog.</p>
|
||||||
<input type="text" name="monetization_pointer" style="width:100%" value="{{.Collection.Monetization}}" placeholder="$wallet.example.com/alice" />
|
<input type="text" name="monetization_pointer" style="width:100%" value="{{.Collection.Monetization}}" placeholder="$wallet.example.com/alice" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -245,6 +292,13 @@ var $customDomain = document.getElementById('domain-alias');
|
||||||
var $customHandleEnv = document.getElementById('custom-handle-env');
|
var $customHandleEnv = document.getElementById('custom-handle-env');
|
||||||
var $normalHandleEnv = document.getElementById('normal-handle-env');
|
var $normalHandleEnv = document.getElementById('normal-handle-env');
|
||||||
|
|
||||||
|
var $emailSubsCheck = document.getElementById('email_subs');
|
||||||
|
var $letterReply = document.getElementById('letter_reply');
|
||||||
|
H.getEl('email_subs').on('click', function() {
|
||||||
|
let show = $emailSubsCheck.checked
|
||||||
|
$letterReply.disabled = !show
|
||||||
|
})
|
||||||
|
|
||||||
if (matchMedia('(pointer:fine)').matches) {
|
if (matchMedia('(pointer:fine)').matches) {
|
||||||
// Only initialize Ace editor on devices with a mouse
|
// Only initialize Ace editor on devices with a mouse
|
||||||
var opt = {
|
var opt = {
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
{{if not .SingleUser}}<a href="/about">about</a>{{end}}
|
{{if not .SingleUser}}<a href="/about">about</a>{{end}}
|
||||||
{{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
|
{{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
|
||||||
<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
|
<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
|
||||||
|
{{if not .SingleUser}}<a href="/contact">contact</a>{{end}}
|
||||||
{{if not .SingleUser}}<a href="/privacy">privacy</a>{{end}}
|
{{if not .SingleUser}}<a href="/privacy">privacy</a>{{end}}
|
||||||
{{if .WFModesty}}
|
{{if .WFModesty}}
|
||||||
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
|
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/me/c/{{.Username}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Username)}}class="selected"{{end}}>Customize</a>
|
<a href="/me/c/{{.Username}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Username)}}class="selected"{{end}}>Customize</a>
|
||||||
<a href="/me/c/{{.Username}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
|
<a href="/me/c/{{.Username}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
|
||||||
|
<a href="/me/c/{{.Username}}/subscribers" {{if hasSuffix .Path "/subscribers"}}class="selected"{{end}}>Subscribers</a>
|
||||||
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
|
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
|
||||||
</nav>
|
</nav>
|
||||||
</nav>
|
</nav>
|
||||||
|
@ -83,6 +84,7 @@
|
||||||
<title>{{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}—{{end}} {{.SiteName}}</title>
|
<title>{{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}—{{end}} {{.SiteName}}</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
<link rel="stylesheet" type="text/css" href="/css/write.css" />
|
||||||
|
{{if .CustomCSS}}<link rel="stylesheet" type="text/css" href="/local/custom.css" />{{end}}
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#888888" />
|
<meta name="theme-color" content="#888888" />
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
{{if .CanPost}}<a href="{{if .SingleUser}}/me/new{{else}}/#{{.Alias}}{{end}}" class="btn gentlecta">New Post</a>{{end}}
|
{{if .CanPost}}<a href="{{if .SingleUser}}/me/new{{else}}/#{{.Alias}}{{end}}" class="btn gentlecta">New Post</a>{{end}}
|
||||||
<a href="/me/c/{{.Alias}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Alias)}}class="selected"{{end}}>Customize</a>
|
<a href="/me/c/{{.Alias}}" {{if and (hasPrefix .Path "/me/c/") (hasSuffix .Path .Alias)}}class="selected"{{end}}>Customize</a>
|
||||||
<a href="/me/c/{{.Alias}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
|
<a href="/me/c/{{.Alias}}/stats" {{if hasSuffix .Path "/stats"}}class="selected"{{end}}>Stats</a>
|
||||||
|
<a href="/me/c/{{.Alias}}/subscribers" {{if hasSuffix .Path "/subscribers"}}class="selected"{{end}}>Subscribers</a>
|
||||||
<a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog →</a>
|
<a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog →</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue