Compare commits
224 commits
fix-update
...
develop
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 | ||
|
27e82f0409 | ||
|
2275a288b9 | ||
|
f96f8268f0 | ||
|
e96e657430 | ||
|
f404f7b928 | ||
|
7dda53146d | ||
|
e2fde518ca | ||
|
c75507ca8f | ||
|
82e7dcd3f3 | ||
|
361c887e2c | ||
|
c6323dba8c | ||
|
dcc6f036c6 | ||
|
d7d44cb4e1 | ||
|
2a496bd000 | ||
|
15047b7288 | ||
|
d1afa44a2e | ||
|
ac40b2f733 | ||
|
e2b2ba4577 | ||
|
cc75be1eb5 | ||
|
221d0d7dbb | ||
|
cc9705447d | ||
|
06968e7341 | ||
|
142c5d6cec | ||
|
526db318c4 | ||
|
118eb732f4 | ||
|
c3ae4e6d3c | ||
|
df7be46417 | ||
|
fc8e209def | ||
|
e963755393 | ||
|
2288ccf2a2 | ||
|
2ea235f0c4 |
70 changed files with 2996 additions and 216 deletions
|
@ -1,2 +1 @@
|
|||
Dockerfile
|
||||
.git
|
||||
|
|
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)
|
||||
|
|
9
.github/workflows/docker-publish.yml
vendored
9
.github/workflows/docker-publish.yml
vendored
|
@ -30,6 +30,14 @@ jobs:
|
|||
- name: Checkout repository
|
||||
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
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
|
@ -56,6 +64,7 @@ jobs:
|
|||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
|
|
@ -18,11 +18,11 @@ First, you'll want to clone the WriteFreely repo, install development dependenci
|
|||
|
||||
### 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.
|
||||
|
||||
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
|
||||
|
||||
|
|
26
Dockerfile
26
Dockerfile
|
@ -1,13 +1,14 @@
|
|||
# Build image
|
||||
FROM golang:1.19-alpine as build
|
||||
FROM golang:1.21-alpine3.18 as build
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/writefreely/writefreely
|
||||
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 add --update nodejs npm make g++ git
|
||||
RUN npm install -g less less-plugin-clean-css
|
||||
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
|
||||
|
||||
COPY . .
|
||||
|
@ -18,9 +19,9 @@ ENV GO111MODULE=on
|
|||
ENV NODE_OPTIONS=--openssl-legacy-provider
|
||||
|
||||
RUN make build \
|
||||
&& make ui
|
||||
RUN mkdir /stage && \
|
||||
cp -R /go/bin \
|
||||
&& make ui \
|
||||
&& mkdir /stage \
|
||||
&& cp -R /go/bin \
|
||||
/go/src/github.com/writefreely/writefreely/templates \
|
||||
/go/src/github.com/writefreely/writefreely/static \
|
||||
/go/src/github.com/writefreely/writefreely/pages \
|
||||
|
@ -29,9 +30,11 @@ RUN mkdir /stage && \
|
|||
/stage
|
||||
|
||||
# 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
|
||||
|
||||
WORKDIR /go
|
||||
|
@ -40,3 +43,6 @@ EXPOSE 8080
|
|||
USER daemon
|
||||
|
||||
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"]
|
30
Makefile
30
Makefile
|
@ -1,5 +1,5 @@
|
|||
GITREV=`git describe | cut -c 2-`
|
||||
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'"
|
||||
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)' -extldflags '-static'"
|
||||
|
||||
GOCMD=go
|
||||
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
||||
|
@ -27,37 +27,43 @@ build-linux: deps
|
|||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./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
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./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
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./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
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./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
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./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
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.19.x -out writefreely -pkg ./cmd/writefreely .
|
||||
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-docker :
|
||||
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
||||
|
@ -83,9 +89,9 @@ install : build
|
|||
|
||||
release : clean ui
|
||||
mkdir -p $(BUILDPATH)
|
||||
cp -r templates $(BUILDPATH)
|
||||
cp -r pages $(BUILDPATH)
|
||||
cp -r static $(BUILDPATH)
|
||||
rsync -av --exclude=".*" templates $(BUILDPATH)
|
||||
rsync -av --exclude=".*" pages $(BUILDPATH)
|
||||
rsync -av --exclude=".*" static $(BUILDPATH)
|
||||
rm -r $(BUILDPATH)/static/local
|
||||
scripts/invalidate-css.sh $(BUILDPATH)
|
||||
mkdir $(BUILDPATH)/keys
|
||||
|
@ -109,6 +115,10 @@ release : clean ui
|
|||
mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(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
|
||||
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)
|
||||
|
|
|
@ -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!
|
||||
|
||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||
* [Nanos Repository](https://repo.ops.city/v2/packages/eyberg/writefreely/show)
|
||||
|
||||
## Documentation
|
||||
|
||||
|
@ -86,4 +87,4 @@ Before contributing anything, please read our [Contributing Guide](https://githu
|
|||
|
||||
## 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).
|
||||
|
|
290
account.go
290
account.go
|
@ -13,6 +13,8 @@ package writefreely
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/writefreely/writefreely/mailer"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
@ -323,6 +325,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
EmailEnabled bool
|
||||
LoginUsername string
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
|
@ -330,6 +333,7 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
To: r.FormValue("to"),
|
||||
Message: template.HTML(""),
|
||||
Flashes: []template.HTML{},
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||
}
|
||||
|
||||
|
@ -875,12 +879,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
*UserPage
|
||||
*Collection
|
||||
Silenced bool
|
||||
|
||||
config.EmailCfg
|
||||
LetterReplyTo string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||
Collection: c,
|
||||
Silenced: silenced,
|
||||
EmailCfg: app.cfg.Email,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if obj.EmailCfg.Enabled() {
|
||||
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
|
||||
}
|
||||
|
||||
showUserPage(w, "collection", obj)
|
||||
return nil
|
||||
|
@ -1056,12 +1067,15 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
Collection *Collection
|
||||
TopPosts *[]PublicPost
|
||||
APFollowers int
|
||||
EmailEnabled bool
|
||||
EmailSubscribers int
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||
VisitsBlog: alias,
|
||||
Collection: c,
|
||||
TopPosts: topPosts,
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
Silenced: silenced,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
|
@ -1072,11 +1086,73 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
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)
|
||||
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 {
|
||||
fullUser, err := app.db.GetUserByID(u.ID)
|
||||
if err != nil {
|
||||
|
@ -1165,6 +1241,220 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
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 {
|
||||
session, err := app.sessionStore.Get(r, "t")
|
||||
if err != nil {
|
||||
|
|
240
activitypub.go
240
activitypub.go
|
@ -22,6 +22,7 @@ import (
|
|||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -45,6 +46,11 @@ const (
|
|||
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
|
||||
|
||||
func initActivityPub(app *App) {
|
||||
|
@ -65,6 +71,20 @@ type RemoteUser struct {
|
|||
SharedInbox string
|
||||
URL 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 {
|
||||
|
@ -181,7 +201,7 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
|||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
|
||||
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 {
|
||||
pp.Collection = res
|
||||
o := pp.ActivityObject(app)
|
||||
|
@ -337,11 +357,60 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
a := streams.NewAccept()
|
||||
p := c.PersonObject()
|
||||
var to *url.URL
|
||||
var isFollow, isUnfollow bool
|
||||
var isFollow, isUnfollow, isLike, isUnlike bool
|
||||
var likePostID, unlikePostID string
|
||||
fullActor := &activitystreams.Person{}
|
||||
var remoteUser *RemoteUser
|
||||
|
||||
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 {
|
||||
isFollow = true
|
||||
|
||||
|
@ -367,6 +436,17 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
a.AppendObject(f.Raw())
|
||||
_, to = f.GetActor(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)
|
||||
|
||||
// First get actor information
|
||||
|
@ -380,8 +460,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
return impart.RenderActivityJSON(w, m, http.StatusOK)
|
||||
},
|
||||
UndoCallback: func(u *streams.Undo) error {
|
||||
isUnfollow = true
|
||||
|
||||
m["@context"] = []string{activitystreams.Namespace}
|
||||
b, _ := json.Marshal(m)
|
||||
if debugging {
|
||||
|
@ -389,6 +467,37 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
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)
|
||||
// TODO: get actor from object.object, not object
|
||||
obj := u.Raw().GetObjectIRI(0)
|
||||
|
@ -421,6 +530,81 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
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() {
|
||||
if to == nil {
|
||||
if debugging {
|
||||
|
@ -455,6 +639,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
if remoteUser != nil {
|
||||
followerID = remoteUser.ID
|
||||
} else {
|
||||
// TODO: use apAddRemoteUser() here, instead!
|
||||
// Add follower 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 {
|
||||
|
@ -808,13 +993,28 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
|||
log.Info("Not found; fetching actor %s remotely", actorIRI)
|
||||
actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI)
|
||||
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."}
|
||||
}
|
||||
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."}
|
||||
}
|
||||
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 {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
@ -935,6 +1135,34 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
|||
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) {
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||
}
|
||||
|
|
2
admin.go
2
admin.go
|
@ -208,7 +208,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
|
||||
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
|
||||
p.TotalUsers = app.db.GetAllUsersCount()
|
||||
ttlPages := p.TotalUsers / adminUsersPerPage
|
||||
ttlPages := (p.TotalUsers - 1) / adminUsersPerPage + 1
|
||||
p.TotalPages = []int{}
|
||||
for i := 1; i <= int(ttlPages); i++ {
|
||||
p.TotalPages = append(p.TotalPages, i)
|
||||
|
|
16
app.go
16
app.go
|
@ -49,6 +49,7 @@ const (
|
|||
staticDir = "static"
|
||||
assumedTitleLen = 80
|
||||
postsPerPage = 10
|
||||
postsPerArchPage = 40
|
||||
|
||||
serverSoftware = "WriteFreely"
|
||||
softwareURL = "https://writefreely.org"
|
||||
|
@ -58,7 +59,7 @@ var (
|
|||
debugging bool
|
||||
|
||||
// Software version can be set from git env using -ldflags
|
||||
softwareVer = "0.14.0"
|
||||
softwareVer = "0.15.1"
|
||||
|
||||
// DEPRECATED VARS
|
||||
isSingleUser bool
|
||||
|
@ -365,7 +366,7 @@ func pageForReq(app *App, r *http.Request) page.StaticPage {
|
|||
}
|
||||
|
||||
// Use custom style, if file exists
|
||||
if _, err := os.Stat(filepath.Join(staticDir, "local", "custom.css")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil {
|
||||
p.CustomCSS = true
|
||||
}
|
||||
|
||||
|
@ -428,6 +429,13 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
|||
|
||||
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
|
||||
if apper.App().cfg.App.LocalTimeline {
|
||||
log.Info("Initializing local timeline...")
|
||||
|
@ -881,12 +889,12 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
if isAdmin {
|
||||
// Abort if trying to create admin user, but one already exists
|
||||
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 {
|
||||
// Abort if trying to create regular user, but no admin exists yet
|
||||
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]")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -35,9 +35,17 @@ import (
|
|||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
collAttrLetterReplyTo = "letter_reply_to"
|
||||
|
||||
collMaxLengthTitle = 255
|
||||
collMaxLengthDescription = 160
|
||||
)
|
||||
|
||||
type (
|
||||
// TODO: add Direction to db
|
||||
// TODO: add Language to db
|
||||
|
@ -81,6 +89,14 @@ type (
|
|||
TotalPages int
|
||||
Silenced bool
|
||||
}
|
||||
|
||||
CollectionNav struct {
|
||||
*Collection
|
||||
Path string
|
||||
SingleUser bool
|
||||
CanPost bool
|
||||
}
|
||||
|
||||
SubmittedCollection struct {
|
||||
// Data used for updating a given collection
|
||||
ID int64
|
||||
|
@ -91,17 +107,19 @@ type (
|
|||
Privacy int `schema:"privacy" json:"privacy"`
|
||||
Pass string `schema:"password" json:"password"`
|
||||
MathJax bool `schema:"mathjax" json:"mathjax"`
|
||||
EmailSubs bool `schema:"email_subs" json:"email_subs"`
|
||||
Handle string `schema:"handle" json:"handle"`
|
||||
|
||||
// Actual collection values updated in the DB
|
||||
Alias *string `schema:"alias" json:"alias"`
|
||||
Title *string `schema:"title" json:"title"`
|
||||
Description *string `schema:"description" json:"description"`
|
||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
|
||||
Script *sql.NullString `schema:"script" json:"script"`
|
||||
Signature *sql.NullString `schema:"signature" json:"signature"`
|
||||
StyleSheet *string `schema:"style_sheet" json:"style_sheet"`
|
||||
Script *string `schema:"script" json:"script"`
|
||||
Signature *string `schema:"signature" json:"signature"`
|
||||
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"`
|
||||
Format *sql.NullString `schema:"format" json:"format"`
|
||||
}
|
||||
|
@ -361,6 +379,10 @@ func (c *Collection) RenderMathJax() bool {
|
|||
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 {
|
||||
if c.Monetization == "" {
|
||||
return ""
|
||||
|
@ -586,7 +608,7 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
}
|
||||
|
||||
ps, 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 {
|
||||
return err
|
||||
}
|
||||
|
@ -612,11 +634,15 @@ type CollectionPage struct {
|
|||
IsWelcome bool
|
||||
IsOwner bool
|
||||
IsCollLoggedIn bool
|
||||
Honeypot string
|
||||
IsSubscriber bool
|
||||
CanPin bool
|
||||
Username string
|
||||
Monetization string
|
||||
Flash template.HTML
|
||||
Collections *[]Collection
|
||||
PinnedPosts *[]PublicPost
|
||||
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
|
||||
|
@ -802,15 +828,18 @@ func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPost
|
|||
return u, nil
|
||||
}
|
||||
|
||||
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
|
||||
func newDisplayCollection(c *Collection, cr *collectionReq, page int) (*DisplayCollection, error) {
|
||||
coll := &DisplayCollection{
|
||||
CollectionObj: NewCollectionObj(c),
|
||||
CurrentPage: page,
|
||||
Prefix: cr.prefix,
|
||||
IsTopLevel: isSingleUser,
|
||||
}
|
||||
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||
return coll
|
||||
err := c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return coll, nil
|
||||
}
|
||||
|
||||
// getCollectionPage returns the collection page as an int. If the parsed page value is not
|
||||
|
@ -853,18 +882,31 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
|
||||
// Serve ActivityStreams data now, if requested
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||
if IsActivityPubRequest(r) {
|
||||
ac := c.PersonObject()
|
||||
ac.Context = []interface{}{activitystreams.Namespace}
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
||||
}
|
||||
|
||||
// Fetch extra data about the Collection
|
||||
// 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 {
|
||||
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
|
||||
if !app.cfg.App.SingleUser {
|
||||
|
@ -873,7 +915,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
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
|
||||
displayPage := CollectionPage{
|
||||
|
@ -882,14 +924,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
StaticPage: pageForReq(app, r),
|
||||
IsCustomDomain: cr.isCustomDomain,
|
||||
IsWelcome: r.FormValue("greeting") != "",
|
||||
Honeypot: spam.HoneypotFieldName(),
|
||||
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.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
|
||||
var owner *User
|
||||
if u != nil {
|
||||
displayPage.Username = u.Username
|
||||
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||
displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID)
|
||||
if displayPage.IsOwner {
|
||||
// Add in needed information for users viewing their own collection
|
||||
owner = u
|
||||
|
@ -926,6 +974,9 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
collTmpl := "collection"
|
||||
if app.cfg.App.Chorus {
|
||||
collTmpl = "chorus-collection"
|
||||
} else if isArchiveView(r) {
|
||||
displayPage.NavSuffix = "/archive/"
|
||||
collTmpl = "collection-archive"
|
||||
}
|
||||
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
||||
if err != nil {
|
||||
|
@ -952,6 +1003,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
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 {
|
||||
vars := mux.Vars(r)
|
||||
handle := vars["handle"]
|
||||
|
@ -987,7 +1042,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
return err
|
||||
}
|
||||
|
||||
coll := newDisplayCollection(c, cr, page)
|
||||
coll, _ := newDisplayCollection(c, cr, page)
|
||||
|
||||
taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner)
|
||||
if err != nil {
|
||||
|
@ -1085,7 +1140,7 @@ func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request)
|
|||
return err
|
||||
}
|
||||
|
||||
coll := newDisplayCollection(c, cr, page)
|
||||
coll, _ := newDisplayCollection(c, cr, page)
|
||||
coll.Language = lang
|
||||
coll.NavSuffix = fmt.Sprintf("/lang:%s", lang)
|
||||
|
||||
|
|
|
@ -170,11 +170,26 @@ type (
|
|||
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 struct {
|
||||
Server ServerCfg `ini:"server"`
|
||||
Database DatabaseCfg `ini:"database"`
|
||||
App AppCfg `ini:"app"`
|
||||
Email EmailCfg `ini:"email"`
|
||||
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
||||
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
||||
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
||||
|
@ -235,6 +250,11 @@ func (ac *AppCfg) LandingPath() string {
|
|||
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 {
|
||||
if !ac.OpenRegistration {
|
||||
return ""
|
||||
|
|
|
@ -12,12 +12,14 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SetupData struct {
|
||||
|
@ -80,6 +82,8 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
isDevEnv := envType == "Development"
|
||||
isStandalone := envType == "Production, standalone"
|
||||
|
||||
_, isDocker := os.LookupEnv("WRITEFREELY_DOCKER")
|
||||
|
||||
data.Config.Server.Dev = isDevEnv
|
||||
|
||||
if isDevEnv || !isStandalone {
|
||||
|
@ -150,6 +154,16 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
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()
|
||||
}
|
||||
|
||||
|
|
388
database.go
388
database.go
|
@ -14,13 +14,18 @@ import (
|
|||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/writeas/web-core/silobridge"
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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/zero"
|
||||
uuid "github.com/nu7hatch/gouuid"
|
||||
|
@ -112,8 +117,9 @@ type writestore interface {
|
|||
DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
|
||||
ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
|
||||
|
||||
GetPostsCount(c *CollectionObj, includeFuture bool)
|
||||
GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
|
||||
GetPostLikeCounts(postID string) (int64, 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)
|
||||
|
||||
|
@ -173,6 +179,13 @@ func (db *datastore) upsert(indexedCols ...string) string {
|
|||
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 {
|
||||
if db.driverName == driverSQLite {
|
||||
return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit)
|
||||
|
@ -566,7 +579,7 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
|
|||
|
||||
expirationVal := "NULL"
|
||||
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)
|
||||
|
@ -578,6 +591,37 @@ func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int,
|
|||
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) {
|
||||
var userID, collID int64 = -1, -1
|
||||
var coll *Collection
|
||||
|
@ -854,12 +898,20 @@ func (db *datastore) GetCollectionFromDomain(host string) (*Collection, 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().
|
||||
SetStringPtr(c.Title, "title").
|
||||
SetStringPtr(c.Description, "description").
|
||||
SetNullString(c.StyleSheet, "style_sheet").
|
||||
SetNullString(c.Script, "script").
|
||||
SetNullString(c.Signature, "post_signature")
|
||||
SetStringPtr(c.StyleSheet, "style_sheet").
|
||||
SetStringPtr(c.Script, "script").
|
||||
SetStringPtr(c.Signature, "post_signature")
|
||||
|
||||
if c.Format != nil {
|
||||
cf := &CollectionFormat{Format: c.Format.String}
|
||||
|
@ -973,6 +1025,40 @@ func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias st
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if q.Updates != "" {
|
||||
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
|
||||
|
@ -1091,6 +1177,12 @@ func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error)
|
|||
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()
|
||||
if ownerName.Valid {
|
||||
res.Owner = &PublicUser{Username: ownerName.String}
|
||||
|
@ -1153,10 +1245,22 @@ func (db *datastore) GetPostProperty(id string, collectionID int64, property str
|
|||
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
|
||||
// standard (non-pinned) posts. It will return future posts if `includeFuture`
|
||||
// is true.
|
||||
func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
|
||||
func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) error {
|
||||
var count int64
|
||||
timeCondition := ""
|
||||
if !includeFuture {
|
||||
|
@ -1169,16 +1273,18 @@ func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
|
|||
case err != nil:
|
||||
log.Error("Failed selecting from collections: %v", err)
|
||||
c.TotalPosts = 0
|
||||
return err
|
||||
}
|
||||
|
||||
c.TotalPosts = int(count)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPosts retrieves all posts for the given Collection.
|
||||
// It will return future posts if `includeFuture` 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
|
||||
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
|
||||
|
||||
cf := c.NewFormat()
|
||||
|
@ -1192,6 +1298,9 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
|||
if page == 0 {
|
||||
start = 0
|
||||
pagePosts = 1000
|
||||
} else if contentType == postArch {
|
||||
pagePosts = postsPerArchPage
|
||||
start = page*pagePosts - pagePosts
|
||||
}
|
||||
|
||||
limitStr := ""
|
||||
|
@ -1206,6 +1315,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
|||
if !includePinned {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Error("Failed selecting from posts: %v", err)
|
||||
|
@ -1226,7 +1336,13 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
|||
p.augmentContent(c)
|
||||
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()
|
||||
if err != nil {
|
||||
|
@ -1247,7 +1363,7 @@ func (db *datastore) GetAllPostsTaggedIDs(c *Collection, tag string, includeFutu
|
|||
|
||||
timeCondition := ""
|
||||
if !includeFuture {
|
||||
timeCondition = "AND created <= NOW()"
|
||||
timeCondition = "AND created <= " + db.now()
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
@ -1415,7 +1531,7 @@ ORDER BY created `+order+limitStr, collID, lang)
|
|||
}
|
||||
|
||||
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 {
|
||||
log.Error("Failed selecting from followers: %v", err)
|
||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."}
|
||||
|
@ -1425,7 +1541,7 @@ func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
|
|||
followers := []RemoteUser{}
|
||||
for rows.Next() {
|
||||
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)
|
||||
}
|
||||
return &followers, nil
|
||||
|
@ -2968,3 +3084,247 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string,
|
|||
}
|
||||
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
|
||||
}
|
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
|
||||
for _, c := range *colls {
|
||||
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 {
|
||||
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 != "" {
|
||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
|
||||
} 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 := ""
|
||||
|
|
75
go.mod
75
go.mod
|
@ -1,26 +1,42 @@
|
|||
module github.com/writefreely/writefreely
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/fatih/color v1.15.0
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
|
||||
github.com/fatih/color v1.17.0
|
||||
github.com/go-ini/ini v1.67.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/gorilla/csrf v1.7.1
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/schema v1.2.0
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
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/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.17
|
||||
github.com/microcosm-cc/bluemonday v1.0.25
|
||||
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/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/urfave/cli/v2 v2.25.7
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
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/activityserve v0.0.0-20230428180247-dc13a4f4d835
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1
|
||||
|
@ -30,52 +46,53 @@ require (
|
|||
github.com/writeas/import v0.2.1
|
||||
github.com/writeas/monday v1.3.0
|
||||
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/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b
|
||||
github.com/writefreely/go-nodeinfo v1.2.0
|
||||
golang.org/x/crypto v0.13.0
|
||||
golang.org/x/net v0.15.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/net v0.30.0
|
||||
)
|
||||
|
||||
require github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
|
||||
require (
|
||||
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/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // 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.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
|
||||
github.com/fatih/structs v1.1.0 // 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/gologme/log v1.2.0 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gosimple/unidecode v1.0.1 // 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/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // 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/sasha-s/go-deadlock v0.3.1 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
|
||||
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
|
||||
github.com/writeas/openssl-go v1.0.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
github.com/writeas/slug v1.2.0 // 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/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
go 1.19
|
||||
go 1.21
|
||||
|
|
199
go.sum
199
go.sum
|
@ -1,5 +1,12 @@
|
|||
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
||||
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/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||
|
@ -15,8 +22,8 @@ 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.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
@ -24,40 +31,72 @@ github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0
|
|||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
|
||||
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/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.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-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
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/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/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/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/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
|
||||
github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
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/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
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.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
||||
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
||||
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es=
|
||||
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=
|
||||
|
@ -65,33 +104,53 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
|
|||
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/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/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/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
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/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.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/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU=
|
||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
|
||||
github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
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/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/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/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
|
@ -100,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/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/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/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
|
||||
|
@ -111,11 +173,15 @@ 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/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
|
||||
github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
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/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
|
||||
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw=
|
||||
|
@ -149,33 +215,58 @@ github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ5
|
|||
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/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
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.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
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-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-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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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=
|
||||
|
@ -183,34 +274,60 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
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.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.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.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.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.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
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 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/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.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
||||
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/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
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.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)
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -818,7 +818,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
return
|
||||
} else if err.Status == http.StatusNotFound {
|
||||
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
|
||||
return
|
||||
}
|
||||
|
|
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
|
||||
}
|
|
@ -60,6 +60,35 @@ nav#admin {
|
|||
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 {
|
||||
|
|
|
@ -210,6 +210,10 @@ body {
|
|||
pre {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.flash {
|
||||
text-align: center;
|
||||
margin-bottom: 4em;
|
||||
}
|
||||
}
|
||||
&#subpage {
|
||||
#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 {
|
||||
p.badge {
|
||||
font-size: 0.9em;
|
||||
|
@ -827,6 +851,9 @@ input {
|
|||
margin: 0 auto 3em;
|
||||
font-size: 1.2em;
|
||||
|
||||
&.toosmall {
|
||||
max-width: 25em;
|
||||
}
|
||||
&.tight {
|
||||
max-width: 30em;
|
||||
}
|
||||
|
@ -1597,6 +1624,18 @@ pre.code-block {
|
|||
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 {
|
||||
font-family: @sansFont;
|
||||
font-size: 1.1em;
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */
|
||||
src: local('Open Sans'), local('OpenSans'),
|
||||
url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -17,7 +16,6 @@
|
|||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: optional;
|
||||
src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */
|
||||
src: local('Open Sans Bold'), local('OpenSans-Bold'),
|
||||
url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -31,7 +29,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora'), local('Lora-Regular'),
|
||||
url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -44,7 +41,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora Bold'), local('Lora-Bold'),
|
||||
url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -56,7 +52,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora Italic'), local('Lora-Italic'),
|
||||
url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
|
|
@ -30,7 +30,7 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
article, pre, .hljs {
|
||||
article, pre, .hljs, #wrapper.archive ul {
|
||||
padding: 0.5em 2rem 1.5em;
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
func (db *datastore) typeTinyInt() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
}
|
||||
return "TINYINT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeText() string {
|
||||
return "TEXT"
|
||||
}
|
||||
|
@ -54,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string {
|
|||
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 {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
|
@ -65,6 +79,15 @@ func (db *datastore) typeDateTime() string {
|
|||
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 {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
|
|
|
@ -65,9 +65,13 @@ var migrations = []Migration{
|
|||
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||
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
|
||||
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
|
||||
|
|
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
|
||||
}
|
|
@ -46,7 +46,7 @@ func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config {
|
|||
Software: nodeinfo.SoftwareMeta{
|
||||
HomePage: softwareURL,
|
||||
GitHub: "https://github.com/writefreely/writefreely",
|
||||
Follow: "https://writing.exchange/@write_as",
|
||||
Follow: "https://writing.exchange/@writefreely",
|
||||
},
|
||||
MaxBlogs: cfg.App.MaxBlogs,
|
||||
PublicReader: cfg.App.LocalTimeline,
|
||||
|
|
|
@ -70,6 +70,8 @@ func (c genericOauthClient) buildLoginURL(state string) (string, error) {
|
|||
|
||||
func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Add("client_id", c.ClientID)
|
||||
form.Add("client_secret", c.ClientSecret)
|
||||
form.Add("grant_type", "authorization_code")
|
||||
form.Add("redirect_uri", c.CallbackLocation)
|
||||
form.Add("scope", c.Scope)
|
||||
|
|
|
@ -13,7 +13,7 @@ package writefreely
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/writeas/slug"
|
||||
"github.com/gosimple/slug"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
|
|
@ -3,6 +3,12 @@
|
|||
<meta itemprop="description" content="Log into {{.SiteName}}.">
|
||||
<style>
|
||||
input{margin-bottom:0.5em;}
|
||||
p.forgot {
|
||||
font-size: 0.8em;
|
||||
margin: 0 auto 1.5rem;
|
||||
text-align: left;
|
||||
max-width: 16rem;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
|
@ -19,6 +25,7 @@ input{margin-bottom:0.5em;}
|
|||
<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="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}}
|
||||
<input type="submit" id="btn-login" value="Login" />
|
||||
</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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
85
posts.go
85
posts.go
|
@ -14,6 +14,7 @@ import (
|
|||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
@ -22,6 +23,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gosimple/slug"
|
||||
"github.com/guregu/null"
|
||||
"github.com/guregu/null/zero"
|
||||
"github.com/kylemcc/twitter-text-go/extract"
|
||||
|
@ -29,7 +31,6 @@ import (
|
|||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/monday"
|
||||
"github.com/writeas/slug"
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
"github.com/writeas/web-core/bots"
|
||||
"github.com/writeas/web-core/converter"
|
||||
|
@ -48,6 +49,12 @@ const (
|
|||
postIDLen = 10
|
||||
|
||||
postMetaDateFormat = "2006-01-02 15:04:05"
|
||||
)
|
||||
|
||||
type PostType string
|
||||
|
||||
const (
|
||||
postArch PostType = "archive"
|
||||
|
||||
shortCodePaid = "<!--paid-->"
|
||||
)
|
||||
|
@ -104,6 +111,7 @@ type (
|
|||
Created time.Time `db:"created" json:"created"`
|
||||
Updated time.Time `db:"updated" json:"updated"`
|
||||
ViewCount int64 `db:"view_count" json:"-"`
|
||||
LikeCount int64 `db:"like_count" json:"likes"`
|
||||
Title zero.String `db:"title" json:"title"`
|
||||
HTMLTitle template.HTML `db:"title" json:"-"`
|
||||
Content string `db:"content" json:"body"`
|
||||
|
@ -126,6 +134,7 @@ type (
|
|||
IsTopLevel bool `json:"-"`
|
||||
DisplayDate string `json:"-"`
|
||||
Views int64 `json:"views"`
|
||||
Likes int64 `json:"likes"`
|
||||
Owner *PublicUser `json:"-"`
|
||||
IsOwner bool `json:"-"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
@ -313,6 +322,8 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
// Display reserved page if that is requested resource
|
||||
if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
|
||||
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" {
|
||||
// Serve static file
|
||||
app.shttp.ServeHTTP(w, r)
|
||||
|
@ -340,6 +351,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
|
||||
var ownerID sql.NullInt64
|
||||
var collectionID sql.NullInt64
|
||||
var title string
|
||||
var content string
|
||||
var font string
|
||||
|
@ -355,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)}
|
||||
}
|
||||
|
||||
err := app.db.QueryRow("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 {
|
||||
case err == sql.ErrNoRows:
|
||||
found = false
|
||||
|
@ -425,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
|
||||
if title == "" && content == "" {
|
||||
gone = true
|
||||
|
@ -489,6 +511,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
if !page.IsOwner && silenced {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
|
||||
if !page.IsOwner && protectDraft {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
page.Silenced = silenced
|
||||
err = templates["post"].ExecuteTemplate(w, "post", page)
|
||||
if err != nil {
|
||||
|
@ -652,9 +678,18 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
// Write success now
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
@ -953,16 +988,23 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if !app.cfg.App.Private && app.cfg.App.Federation {
|
||||
for _, pRes := range *res {
|
||||
if pRes.Code != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
if !app.cfg.App.Private && app.cfg.App.Federation {
|
||||
if !pRes.Post.Created.After(time.Now()) {
|
||||
pRes.Post.Collection.hostName = app.cfg.App.Host
|
||||
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)
|
||||
}
|
||||
|
@ -1150,6 +1192,7 @@ func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
func (p *Post) processPost() PublicPost {
|
||||
res := &PublicPost{Post: p, Views: 0}
|
||||
res.Views = p.ViewCount
|
||||
res.Likes = p.LikeCount
|
||||
// TODO: move to own function
|
||||
loc := monday.FuzzyLocale(p.Language.String)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
cfg := app.cfg
|
||||
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
|
||||
// there's no post with a slug "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{
|
||||
|
@ -1520,7 +1576,7 @@ Are you sure it was ever here?`,
|
|||
fmt.Fprintf(w, "# %s\n\n", p.Title.String)
|
||||
}
|
||||
fmt.Fprint(w, p.Content)
|
||||
} else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||
} else if IsActivityPubRequest(r) {
|
||||
if !postFound {
|
||||
return ErrCollectionPageNotFound
|
||||
}
|
||||
|
@ -1532,6 +1588,15 @@ Are you sure it was ever here?`,
|
|||
} else {
|
||||
p.extractData()
|
||||
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
|
||||
p.formatContent(app.cfg, cr.isCollOwner, true)
|
||||
tp := CollectionPostPage{
|
||||
|
@ -1596,6 +1661,14 @@ func (p *Post) extractData() {
|
|||
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 {
|
||||
return rp.Created.Format(postMetaDateFormat)
|
||||
}
|
||||
|
@ -1611,7 +1684,7 @@ func (rp *RawPost) Updated8601() string {
|
|||
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() {
|
||||
p.Images = extractImages(p.Content)
|
||||
|
|
2
read.go
2
read.go
|
@ -229,11 +229,9 @@ func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page in
|
|||
TotalPages: ttlPages,
|
||||
SelTopic: tag,
|
||||
}
|
||||
if app.cfg.App.Chorus {
|
||||
u := getUserSession(app, r)
|
||||
d.IsAdmin = u != nil && u.IsAdmin()
|
||||
d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
|
||||
}
|
||||
c, err := getReaderSection(app)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
17
routes.go
17
routes.go
|
@ -99,6 +99,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
|||
me.HandleFunc("/c/", handler.User(viewCollections)).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}/subscribers", handler.User(handleViewSubscribers)).Methods("GET")
|
||||
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.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}/pin", 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}/outbox", handler.AllReader(handleFetchCollectionOutbox)).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
|
||||
write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
|
||||
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("/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/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")
|
||||
|
||||
// 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("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
|
||||
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
|
||||
|
@ -216,6 +221,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
|||
func RouteCollections(handler *Handler, r *mux.Router) {
|
||||
r.HandleFunc("/logout", handler.Web(handleLogOutCollection, UserLevelOptional))
|
||||
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))
|
||||
|
@ -223,6 +230,8 @@ func RouteCollections(handler *Handler, r *mux.Router) {
|
|||
r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
|
||||
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
|
||||
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}/edit", handler.Web(handleViewPad, UserLevelUser))
|
||||
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
|
||||
|
|
|
@ -21,6 +21,10 @@ import (
|
|||
const (
|
||||
day = 86400
|
||||
sessionLength = 180 * day
|
||||
|
||||
userEmailCookieName = "ue"
|
||||
userEmailCookieVal = "email"
|
||||
|
||||
cookieName = "wfu"
|
||||
cookieUserVal = "u"
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@ func handleViewSitemap(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
host = c.CanonicalURL()
|
||||
|
||||
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 {
|
||||
log.Error("Error getting posts: %v", 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])
|
||||
}
|
|
@ -188,21 +188,22 @@ var movePostHTML = function(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');
|
||||
let p = H.createPost(post.id, "", post.body)
|
||||
var title = (post.title || p.title || post.id);
|
||||
title = title.replace(/</g, "<");
|
||||
var postLink = (singleUser ? '/d/' : '/') + post.id
|
||||
$post.id = 'post-' + post.id;
|
||||
$post.className = 'post';
|
||||
$post.innerHTML = '<h3><a href="/' + post.id + '">' + title + '</a></h3>';
|
||||
$post.innerHTML = '<h3><a href="' + postLink + '">' + title + '</a></h3>';
|
||||
|
||||
var posted = "";
|
||||
if (post.created) {
|
||||
posted = getFormattedDate(new Date(post.created))
|
||||
}
|
||||
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) {
|
||||
$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>';
|
||||
|
|
|
@ -14,8 +14,8 @@ import (
|
|||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"os"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
|
@ -70,14 +70,14 @@ func initTemplate(parentDir, name string) {
|
|||
filepath.Join(parentDir, templatesDir, "base.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"
|
||||
files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
|
||||
}
|
||||
if name == "chorus-collection" || name == "chorus-collection-post" {
|
||||
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"))
|
||||
}
|
||||
templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<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>
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@
|
|||
<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}}
|
||||
<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>
|
||||
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
|
||||
<li class="separator"><hr /></li>
|
||||
|
|
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}}
|
|
@ -55,7 +55,8 @@
|
|||
{{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}}
|
||||
{{ 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}}
|
||||
{{ end }}
|
||||
</nav>
|
||||
|
|
|
@ -54,6 +54,7 @@
|
|||
{{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}}/stats">Stats</a></li>
|
||||
<li><a href="/me/c/{{.Alias}}/subscribers">Subscribers</a></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}}
|
||||
<li><a href="/me/posts/"><img class="ic-18dp" src="/img/ic_list_dark@2x.png" /> View Drafts</a></li>
|
||||
|
@ -103,6 +104,12 @@
|
|||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="alert success flash">
|
||||
<p>{{.Flash}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{template "posts" .}}
|
||||
|
||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||
|
@ -115,6 +122,8 @@
|
|||
{{end}}
|
||||
</nav>{{end}}
|
||||
|
||||
{{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}}
|
||||
|
||||
{{if .Posts}}</section>{{else}}</div>{{end}}
|
||||
|
||||
{{if .ShowFooterBranding }}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
<a href="/about">about</a>
|
||||
{{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}}
|
||||
<a href="/contact">contact</a>
|
||||
<a href="/privacy">privacy</a>
|
||||
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
|
||||
{{else}}
|
||||
|
@ -25,6 +26,7 @@
|
|||
<ul>
|
||||
<li><a href="/about">about</a></li>
|
||||
{{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>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -105,3 +105,28 @@
|
|||
<script type="text/javascript" id="MathJax-script" src="/js/mathjax/tex-svg-full.js" async>
|
||||
</script>
|
||||
{{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}}
|
|
@ -62,3 +62,15 @@
|
|||
{{ 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}}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
<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>
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
<meta name="description" content="{{.Description}}">
|
||||
<meta itemprop="name" content="{{.SiteName}}">
|
||||
<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:description" content="{{.Description}}">
|
||||
{{if gt .Views 1}}<meta name="twitter:label1" value="Views">
|
||||
|
|
|
@ -202,8 +202,9 @@ function loadMorePosts() {
|
|||
if (http.readyState == 4) {
|
||||
if (http.status == 200) {
|
||||
var data = JSON.parse(http.responseText);
|
||||
var singleUser = {{ .SingleUser }};
|
||||
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) {
|
||||
$loadMore.el.parentNode.removeChild($loadMore.el);
|
||||
|
|
|
@ -36,7 +36,7 @@ textarea.section.norm {
|
|||
<form name="customize-form" action="/api/collections/{{.Alias}}" method="post" onsubmit="return disableSubmit()">
|
||||
<div id="collection-options">
|
||||
<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" maxlength="160" /></p>
|
||||
</div>
|
||||
|
||||
|
@ -90,6 +90,44 @@ textarea.section.norm {
|
|||
</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">
|
||||
<h2>Display Format</h2>
|
||||
<div class="section">
|
||||
|
@ -254,6 +292,13 @@ var $customDomain = document.getElementById('domain-alias');
|
|||
var $customHandleEnv = document.getElementById('custom-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) {
|
||||
// Only initialize Ace editor on devices with a mouse
|
||||
var opt = {
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
{{if not .SingleUser}}<a href="/about">about</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>
|
||||
{{if not .SingleUser}}<a href="/contact">contact</a>{{end}}
|
||||
{{if not .SingleUser}}<a href="/privacy">privacy</a>{{end}}
|
||||
{{if .WFModesty}}
|
||||
<p style="font-size: 0.9em">powered by <a href="https://writefreely.org">writefreely</a></p>
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
<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}}/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>
|
||||
</nav>
|
||||
</nav>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
{{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}}/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>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
@ -31,14 +31,16 @@ td.none {
|
|||
|
||||
<p>Stats for all time.</p>
|
||||
|
||||
{{if .Federation}}
|
||||
<h3>Fediverse stats</h3>
|
||||
{{if or .Federation .EmailEnabled}}
|
||||
<h3>Subscribers</h3>
|
||||
<table id="fediverse" class="classy export">
|
||||
<tr>
|
||||
<th>Followers</th>
|
||||
{{if .Federation}}<th>Fediverse Followers</th>{{end}}
|
||||
{{if .EmailEnabled}}<th>Email Subscribers</th>{{end}}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{.APFollowers}}</td>
|
||||
{{if .Federation}}<td><a href="/me/c/{{.Collection.Alias}}/subscribers?filter=fediverse">{{.APFollowers}}</a></td>{{end}}
|
||||
{{if .EmailEnabled}}<td><a href="/me/c/{{.Collection.Alias}}/subscribers">{{.EmailSubscribers}}</a></td>{{end}}
|
||||
</tr>
|
||||
</table>
|
||||
{{end}}
|
||||
|
@ -49,11 +51,13 @@ td.none {
|
|||
<th>Post</th>
|
||||
{{if not .Collection}}<th>Blog</th>{{end}}
|
||||
<th class="num">Total Views</th>
|
||||
{{if .Federation}}<th class="num">Likes</th>{{end}}
|
||||
</tr>
|
||||
{{range .TopPosts}}<tr>
|
||||
<td style="word-break: break-all;"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}/{{.ID}}{{end}}">{{if ne .DisplayTitle ""}}{{.DisplayTitle}}{{else}}<em>{{.ID}}</em>{{end}}</a></td>
|
||||
{{ if not $.Collection }}<td>{{if .Collection}}<a href="{{.Collection.CanonicalURL}}">{{.Collection.Title}}</a>{{else}}<em>Draft</em>{{end}}</td>{{ end }}
|
||||
<td class="num">{{.ViewCount}}</td>
|
||||
{{if $.Federation}}<td class="num">{{.LikeCount}}</td>{{end}}
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
|
||||
|
|
98
templates/user/subscribers.tmpl
Normal file
98
templates/user/subscribers.tmpl
Normal file
|
@ -0,0 +1,98 @@
|
|||
{{define "subscribers"}}
|
||||
{{template "header" .}}
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
text-align: right;
|
||||
margin: 1em 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="snug content-container {{if not .CanEmailSub}}clean{{end}}">
|
||||
{{if .Silenced}}
|
||||
{{template "user-silenced"}}
|
||||
{{end}}
|
||||
|
||||
{{if .Collection.Collection}}{{template "collection-breadcrumbs" .}}{{end}}
|
||||
|
||||
<h1>Subscribers</h1>
|
||||
{{if .Collection.Collection}}
|
||||
{{template "collection-nav" .Collection}}
|
||||
|
||||
<nav class="pager sub">
|
||||
<a href="/me/c/{{.Collection.Alias}}/subscribers" {{if eq .Filter ""}}class="selected"{{end}}>Email ({{len .EmailSubs}})</a>
|
||||
<a href="/me/c/{{.Collection.Alias}}/subscribers?filter=fediverse" {{if eq .Filter "fediverse"}}class="selected"{{end}}>Followers ({{len .Followers}})</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{if .Flashes -}}
|
||||
<ul class="errors">
|
||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||
</ul>
|
||||
{{- end}}
|
||||
|
||||
{{ if eq .Filter "fediverse" }}
|
||||
<table class="classy export">
|
||||
<tr>
|
||||
<th style="width: 60%">Username</th>
|
||||
<th colspan="2">Since</th>
|
||||
</tr>
|
||||
|
||||
{{if and (gt (len .Followers) 0) (not .FederationEnabled)}}
|
||||
<div class="alert info">
|
||||
<p><strong>Federation is disabled on this server</strong>, so followers won't receive any new posts.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{ if gt (len .Followers) 0 }}
|
||||
{{range $el := .Followers}}
|
||||
<tr>
|
||||
<td><a href="{{.ActorID}}">@{{.EstimatedHandle}}</a></td>
|
||||
<td>{{.CreatedFriendly}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{ else }}
|
||||
<tr>
|
||||
<td colspan="2">No followers yet.</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
{{ else }}
|
||||
{{if or .CanEmailSub .EmailSubs}}
|
||||
{{if not .CanEmailSub}}
|
||||
<div class="alert info">
|
||||
<p><strong>Email subscriptions are disabled on this server</strong>, so no new emails will be sent out.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .EmailSubsEnabled}}
|
||||
<div class="alert info">
|
||||
<p><strong>Email subscriptions are disabled</strong>. {{if .EmailSubs}}No new emails will be sent out.{{end}} To enable email subscriptions, turn the option on from your blog's <a href="/me/c/{{.Collection.Alias}}#updates">Customize</a> page.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
<table class="classy export">
|
||||
<tr>
|
||||
<th style="width: 60%">Email Address</th>
|
||||
<th colspan="2">Since</th>
|
||||
</tr>
|
||||
|
||||
{{ if .EmailSubs }}
|
||||
{{range $el := .EmailSubs}}
|
||||
<tr>
|
||||
<td><a href="mailto:{{.Email.String}}">{{.Email.String}}</a></td>
|
||||
<td>{{.SubscribedFriendly}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{ else }}
|
||||
<tr>
|
||||
<td colspan="2">No subscribers yet.</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</table>
|
||||
{{end}}
|
||||
{{ end }}
|
||||
|
||||
</div>
|
||||
|
||||
{{template "foot" .}}
|
||||
|
||||
{{template "body-end" .}}
|
||||
{{end}}
|
4
users.go
4
users.go
|
@ -134,3 +134,7 @@ func (u *User) IsAdmin() bool {
|
|||
func (u *User) IsSilenced() bool {
|
||||
return u.Status&UserSilenced != 0
|
||||
}
|
||||
|
||||
func (u *User) IsEmailSubscriber(app *App, collID int64) bool {
|
||||
return app.db.IsEmailSubscriber("", u.ID, collID)
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue