Compare commits

..

8 commits

Author SHA1 Message Date
Matt Baer
98700fc576 Merge branch 'export-title' into import-zip 2019-05-30 10:44:31 -04:00
Sandrockcstm
746f1c7b73 Preparation for console logging JSON files. 2019-05-29 18:17:57 -05:00
Sandrockcstm
dc2be18d60 Finalized layout and styling of import page. 2019-05-29 17:31:48 -05:00
Sandrockcstm
54d82eb01f Updated structs in import.go. Added jsonReader function (WIP). 2019-05-28 15:11:36 -05:00
Sandrockcstm
5e4d5ba0ee Added importCollection and post structs. 2019-05-24 21:55:47 -05:00
Sandrockcstm
ac253cab85 Added import to drop-down menu. 2019-05-23 13:28:55 -05:00
Sandrockcstm
63fdc6a225 Added import page and route to import page. 2019-05-23 12:21:40 -05:00
Sandrockcstm
4063c2c7bf Add dummy import buttons on the export page. 2019-05-23 11:40:13 -05:00
225 changed files with 2803 additions and 34254 deletions

View file

@ -1 +1,2 @@
Dockerfile
.git

View file

@ -1,10 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.go]
indent_style = tab

2
.github/FUNDING.yml vendored
View file

@ -1,2 +0,0 @@
github: writefreely
open_collective: writefreely

View file

@ -1,15 +0,0 @@
version: 2
updates:
- package-ecosystem: "gomod" # See documentation for possible values
directory: "/" # Location of package manifests
open-pull-requests-limit: 50
schedule:
interval: "monthly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "docker"
directory: "/Dockerfile"
schedule:
interval: "daily"

View file

@ -2,4 +2,4 @@
---
- [ ] I have signed the [CLA](https://todo.musing.studio/L1)
- [ ] I have signed the [CLA](https://phabricator.write.as/L1)

View file

@ -1,70 +0,0 @@
name: Build container image, publish as GitHub-package
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
push:
branches: [ main, develop ]
# Publish semver tags as releases.
tags:
- 'v*.*.*'
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- 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 }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3.0.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4.6.0
with:
images: |
ghcr.io/${{ github.repository }}
flavor: latest=true
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker images
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 }}

2
.gitignore vendored
View file

@ -1,9 +1,7 @@
node_modules
*~
*.swp
*.swo
static/local/custom.css
build
tmp
*.ini

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "static/js/mathjax"]
path = static/js/mathjax
url = https://github.com/mathjax/MathJax.git

View file

@ -1,7 +1,7 @@
language: go
go:
- "1.13.x"
- "1.11.x"
env:
- GO111MODULE=on

View file

@ -1,99 +1,26 @@
# Contributing to WriteFreely
Welcome! We're glad you're interested in contributing to WriteFreely.
Welcome! We're glad you're interested in contributing to the WriteFreely project.
For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
To start, we'd suggest checking out [our Phabricator board](https://phabricator.write.as/tag/write_freely/) to see where the project is at and where it's going. You can also [join the WriteFreely forums](https://discuss.write.as/c/writefreely) to start talking about what you'd like to do or see.
For **bug reports**, please [open a GitHub issue](https://github.com/writefreely/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs).
## Asking Questions
## Getting Started
The best place to get answers to your questions is on [our forums](https://discuss.write.as/c/writefreely). You can quickly log in using your GitHub account and ask the community about anything. We're also there to answer your questions and discuss potential changes or features.
There are many ways to contribute to WriteFreely, from code to documentation, to translations, to help in the community!
## Submitting Bugs
See our [Contributing Guide](https://writefreely.org/contribute) on WriteFreely.org for ways to contribute without writing code. Otherwise, please read on.
Please use the [GitHub issue tracker](https://github.com/writeas/writefreely/issues/new) to report any bugs you encounter. We're very responsive there and try to keep open issues to a minimum, so you can help by:
## Working on WriteFreely
* **Only reporting bugs in the issue tracker**
* Providing as much information as possible to replicate the issue, including server logs around the incident
* Including the `[app]` section of your configuration, if related
* Breaking issues into smaller pieces if they're larger or have many parts
First, you'll want to clone the WriteFreely repo, install development dependencies, and build the application from source. Learn how to do this in our [Development Setup](https://writefreely.org/docs/latest/developer/setup) guide.
## Contributing code
### Starting development
We gladly welcome development help, regardless of coding experience. We can also use help [translating the app](https://poeditor.com/join/project/TIZ6HFRFdE) and documenting it!
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.
**Before writing or 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/).
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://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
All stable work lives on the `master` branch. We merge into it only when creating a release. Releases are tagged using semantic versioning.
While developing, we primarily work from the `develop` branch, creating _feature branches_ off of it for new features and fixes. When starting a new feature or fix, you should also create a new branch off of `develop`.
#### Branch naming
For fixes and modifications to existing behavior, branch names should follow a similar pattern to commit messages (see below), such as `fix-post-rendering` or `update-documentation`. You can optionally append a task number, e.g. `fix-post-rendering-T000`.
For new features, branches can be named after the new feature, e.g. `activitypub-mentions` or `import-zip`.
#### Pull request scope
The scope of work on each branch should be as small as possible -- one complete feature, one complete change, or one complete fix. This makes it easier for us to review and accept.
### Writing code
We value reliable, readable, and maintainable code over all else in our work. To help you write that kind of code, we offer a few guiding principles, as well as a few concrete guidelines.
#### Guiding principles
* Write code for other humans, not computers.
* The less complexity, the better. The more someone can understand code just by looking at it, the better.
* Functionality, readability, and maintainability over senseless elegance.
* Only abstract when necessary.
* Keep an eye to the future, but don't pre-optimize at the expense of today's simplicity.
#### Code guidelines
* Format all Go code with `go fmt` before committing (**important!**)
* Follow whitespace conventions established within the project (tabs vs. spaces)
* Add comments to exported Go functions and variables
* Follow Go naming conventions, like using [`mixedCaps`](https://golang.org/doc/effective_go.html#mixed-caps)
* Avoid new dependencies unless absolutely necessary
### Commit messages
We highly value commit messages that follow established form within the project. Generally speaking, we follow the practices [outlined](https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines) in the Pro Git Book. A good commit message will look like the following:
* **Line 1**: A short summary written in the present imperative tense. For example:
* ✔️ **Good**: "Fix post rendering bug"
* ❌ No: ~~"Fixes post rendering bug"~~
* ❌ No: ~~"Fixing post rendering bug"~~
* ❌ No: ~~"Fixed post rendering bug"~~
* ❌ No: ~~"Post rendering bug is fixed now"~~
* **Line 2**: _[left blank]_
* **Line 3**: An added description of what changed, any rationale, etc. -- if necessary
* **Last line**: A mention of any applicable task or issue
* For Phabricator tasks: `Ref T000` or `Closes T000`
* For GitHub issues: `Ref #000` or `Fixes #000`
#### Good examples
When in doubt, look to our existing git history for examples of good commit messages. Here are a few:
* [Rename Suspend status to Silence](https://github.com/writefreely/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e)
* [Show 404 when remote user not found](https://github.com/writefreely/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d)
* [Fix post deletion on Pleroma](https://github.com/writefreely/writefreely/commit/fe82cbb96e3d5c57cfde0db76c28c4ea6dabfe50)
### Submitting pull requests
Like our GitHub issues, we aim to keep our number of open pull requests to a minimum. You can follow a few guidelines to ensure changes are merged quickly.
First, make sure your changes follow the established practices and good form outlined in this guide. This is crucial to our project, and ignoring our practices can delay otherwise important fixes.
Beyond that, we prioritize pull requests in this order:
1. Fixes to open GitHub issues
2. Superficial changes and improvements that don't adversely impact users
3. New features and changes that have been discussed before with the team
Any pull requests that haven't previously been discussed with the team may be extensively delayed or closed, especially if they require a wider consideration before integrating into the project. When in doubt, please reach out [on the forum](https://discuss.write.as) before submitting a pull request.
Once you've done that, please feel free to [submit a pull request](https://github.com/writeas/writefreely/pulls) for any small improvements. For larger projects, please [join our development discussions](https://discuss.write.as/c/writefreely) or [get in touch](https://write.as/contact) so we can talk about what you'd like to work on.

View file

@ -1,40 +1,30 @@
# Build image
FROM golang:1.21-alpine3.18 as build
FROM golang:1.12-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 -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
WORKDIR /go/src/github.com/writefreely/writefreely
RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev
RUN npm install -g less less-plugin-clean-css
RUN go get -u github.com/jteeuwen/go-bindata/...
RUN mkdir -p /go/src/github.com/writeas/writefreely
WORKDIR /go/src/github.com/writeas/writefreely
COPY . .
RUN cat ossl_legacy.cnf > /etc/ssl/openssl.cnf
ENV GO111MODULE=on
ENV NODE_OPTIONS=--openssl-legacy-provider
RUN make build \
&& 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 \
/go/src/github.com/writefreely/writefreely/keys \
/go/src/github.com/writefreely/writefreely/cmd \
&& make ui
RUN mkdir /stage && \
cp -R /go/bin \
/go/src/github.com/writeas/writefreely/templates \
/go/src/github.com/writeas/writefreely/static \
/go/src/github.com/writeas/writefreely/pages \
/go/src/github.com/writeas/writefreely/keys \
/go/src/github.com/writeas/writefreely/cmd \
/stage
# Final image
FROM alpine:3.18.4
RUN apk -U upgrade \
&& apk add --no-cache openssl ca-certificates
FROM alpine:3.8
RUN apk add --no-cache openssl ca-certificates
COPY --from=build --chown=daemon:daemon /stage /go
WORKDIR /go
@ -43,6 +33,3 @@ 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

View file

@ -1,34 +0,0 @@
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"]

143
Makefile
View file

@ -1,5 +1,5 @@
GITREV=`git describe | cut -c 2-`
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)' -extldflags '-static'"
GITREV=`git describe --tags | cut -c 2-`
LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'"
GOCMD=go
GOINSTALL=$(GOCMD) install $(LDFLAGS)
@ -7,63 +7,38 @@ GOBUILD=$(GOCMD) build $(LDFLAGS)
GOTEST=$(GOCMD) test $(LDFLAGS)
GOGET=$(GOCMD) get
BINARY_NAME=writefreely
BUILDPATH=build/$(BINARY_NAME)
DOCKERCMD=docker
IMAGE_NAME=writeas/writefreely
TMPBIN=./tmp
all : build
ci: deps
ci: ci-assets deps
cd cmd/writefreely; $(GOBUILD) -v
build: deps
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite'
build: assets deps
cd cmd/writefreely; $(GOBUILD) -v -tags='sqlite'
build-no-sqlite: deps-no-sqlite
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME)
build-no-sqlite: assets-no-sqlite deps-no-sqlite
cd cmd/writefreely; $(GOBUILD) -v -o $(BINARY_NAME)
build-linux: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
$(GOGET) -u github.com/karalabe/xgo; \
fi
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-windows: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
$(GOGET) -u github.com/karalabe/xgo; \
fi
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-darwin: deps
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
$(GOGET) -u github.com/karalabe/xgo; \
fi
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.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.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.21.x -out writefreely -pkg ./cmd/writefreely .
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
build-docker :
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
@ -71,8 +46,8 @@ build-docker :
test:
$(GOTEST) -v ./...
run:
$(GOINSTALL) -tags='netgo sqlite' ./...
run: dev-assets
$(GOINSTALL) -tags='sqlite' ./...
$(BINARY_NAME) --debug
deps :
@ -87,73 +62,73 @@ install : build
cmd/writefreely/$(BINARY_NAME) --init-db
cd less/; $(MAKE) install $(MFLAGS)
release : clean ui
mkdir -p $(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
release : clean ui assets
mkdir build
cp -r templates build
cp -r pages build
cp -r static build
mkdir build/keys
$(MAKE) build-linux
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-arm6
mv build/$(BINARY_NAME)-linux-arm-6 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm6.tar.gz -C build $(BINARY_NAME)
rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-arm7
mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
rm $(BUILDPATH)/$(BINARY_NAME)
$(MAKE) build-arm64
mv build/$(BINARY_NAME)-linux-arm64 $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME)
rm $(BUILDPATH)/$(BINARY_NAME)
mv build/$(BINARY_NAME)-linux-amd64 build/$(BINARY_NAME)
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz *
rm build/$(BINARY_NAME)
$(MAKE) build-darwin
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)
mv build/$(BINARY_NAME)-darwin-10.6-amd64 build/$(BINARY_NAME)
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz *
rm build/$(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)
rm $(BUILDPATH)/$(BINARY_NAME).exe
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe build/$(BINARY_NAME).exe
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./*
$(MAKE) build-docker
$(MAKE) release-docker
# This assumes you're on linux/amd64
release-linux : clean ui
mkdir -p $(BUILDPATH)
cp -r templates $(BUILDPATH)
cp -r pages $(BUILDPATH)
cp -r static $(BUILDPATH)
mkdir $(BUILDPATH)/keys
mkdir build
cp -r templates build
cp -r pages build
cp -r static build
mkdir build/keys
$(MAKE) build-no-sqlite
mv cmd/writefreely/$(BINARY_NAME) $(BUILDPATH)/$(BINARY_NAME)
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
mv cmd/writefreely/$(BINARY_NAME) build/$(BINARY_NAME)
cd build; tar -cvzf ../$(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz *
release-docker :
$(DOCKERCMD) push $(IMAGE_NAME)
ui : force_look
cd less/; $(MAKE) $(MFLAGS)
cd prose/; $(MAKE) $(MFLAGS)
assets : generate
go-bindata -pkg writefreely -ignore=\\.gitignore schema.sql sqlite.sql
assets-no-sqlite: generate
go-bindata -pkg writefreely -ignore=\\.gitignore schema.sql
dev-assets : generate
go-bindata -pkg writefreely -ignore=\\.gitignore -debug schema.sql sqlite.sql
generate :
@hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
$(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \
fi
$(TMPBIN):
mkdir -p $(TMPBIN)
$(TMPBIN)/go-bindata: deps $(TMPBIN)
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
$(TMPBIN)/xgo: deps $(TMPBIN)
$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
$(GOBUILD) -o $(TMPBIN)/xgo github.com/karalabe/xgo
ci-assets : $(TMPBIN)/go-bindata
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore schema.sql sqlite.sql
clean :
-rm -rf build
-rm -rf tmp
cd less/; $(MAKE) clean $(MFLAGS)
force_look :
force_look :
true

110
README.md
View file

@ -4,17 +4,17 @@
</p>
<hr />
<p align="center">
<a href="https://github.com/writefreely/writefreely/releases/">
<img src="https://img.shields.io/github/release/writefreely/writefreely.svg" alt="Latest release" />
<a href="https://github.com/writeas/writefreely/releases/">
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
</a>
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
</a>
<a href="https://travis-ci.org/writeas/writefreely">
<img src="https://travis-ci.org/writefreely/writefreely.svg" alt="Build status" />
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
</a>
<a href="https://github.com/writefreely/writefreely/releases/latest">
<img src="https://img.shields.io/github/downloads/writefreely/writefreely/total.svg" />
</a>
<a href="https://goreportcard.com/report/github.com/writefreely/writefreely">
<img src="https://goreportcard.com/badge/github.com/writefreely/writefreely" alt="Go Report Card" />
<a href="https://github.com/writeas/writefreely/releases/latest">
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
</a>
<a href="https://hub.docker.com/r/writeas/writefreely/">
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
@ -22,69 +22,93 @@
</p>
&nbsp;
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.
WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath.
![](https://writefreely.org/img/screens/pencil-reader.png)
It's designed to be flexible and share your writing widely, so it's built around plain text and can publish to the _fediverse_ via ActivityPub. It's easy to install and light enough to run on a Raspberry Pi.
[Try the writing experience](https://write.as/new)
[Try the editor](https://write.as/new)
[Find an instance](https://writefreely.org/instances)
## Features
### Made for writing
* Start a blog for yourself, or host a community of writers
* Form larger federated networks, and interact over modern protocols like ActivityPub
* Write on a fast, dead-simple, and distraction-free editor
* [Format text](https://howto.write.as/getting-started) with Markdown
* [Organize posts](https://howto.write.as/organization) with hashtags
* Create [static pages](https://howto.write.as/creating-a-static-page)
* Publish drafts and let others proofread them by sharing a private link
* Create multiple lightweight blogs under a single account
* Export all data in plain text files
* Read a stream of other posts in your writing community
* Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/)
* Designed around user privacy and consent
Built on a plain, auto-saving editor, WriteFreely gives you a distraction-free writing environment. Once published, your words are front and center, and easy to read.
## Hosting
### A connected community
We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as](https://write.as) for individuals, and [WriteFreely.host](https://writefreely.host) for communities. Besides saving you time, as a customer you directly help fund WriteFreely development.
Start writing together, publicly or privately. Connect with other communities, whether running WriteFreely, [Plume](https://joinplu.me/), or other ActivityPub-powered software. And bring members on board from your existing platforms, thanks to our OAuth 2.0 support.
### [![Write.as](https://write.as/img/writeas-wf-readme.png)](https://write.as/)
### Intuitive organization
Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pricing).
Categorize articles [with hashtags](https://writefreely.org/docs/latest/writer/hashtags), and create static pages from normal posts by [_pinning_ them](https://writefreely.org/docs/latest/writer/static) to your blog. Create draft posts and publish to multiple blogs from one account.
### [![WriteFreely.host](https://writefreely.host/img/wfhost-wf-readme.png)](https://writefreely.host)
### International
Blog elements are localized in 20+ languages, and WriteFreely includes first-class support for non-Latin and right-to-left (RTL) script languages.
### Private by default
WriteFreely collects minimal data, and never publicizes more than a writer consents to. Writers can seamlessly create multiple blogs from a single account for different pen names or purposes without publicly revealing their association.
<h2><a href="https://write.as/writefreely"><img src="https://writefreely.org/img/writeas-readme.png" height="32px" alt="Write.as" /></a></h2>
The quickest way to deploy WriteFreely is with [Write.as](https://write.as/writefreely), a hosted service from the team behind WriteFreely. You'll get fully-managed installation, backup, upgrades, and maintenance — and directly fund our free software work ❤️
[**Learn more on Write.as**](https://write.as/writefreely).
[WriteFreely.host](https://writefreely.host) makes it easy to start a close-knit community — to share knowledge, complement your Mastodon instance, or publish updates in your organization. We take care of the hosting, upgrades, backups, and maintenance so you can focus on writing.
## Quick start
WriteFreely deploys as a static binary on any platform and architecture that Go supports. Just use our built-in SQLite support, or add a MySQL database, and you'll be up and running!
WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable.
For common platforms, start with our [pre-built binaries](https://github.com/writefreely/writefreely/releases/) and head over to our [installation guide](https://writefreely.org/start) to get started.
> **Note** this is currently alpha software. We're quickly moving out of this v0.x stage, but while we're in it, there are no guarantees that this is ready for production use.
### Packages
First, download the [latest release](https://github.com/writeas/writefreely/releases/latest) for your OS. It includes everything you need to start your blog.
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
Now extract the files from the archive, change into the directory, and do the following steps:
```bash
# 1) Configure your blog
./writefreely --config
# 2) (if you chose MySQL in the previous step) Log into MySQL and run:
# CREATE DATABASE writefreely;
# 3) (if you chose Multi-user setup) Import the schema with:
./writefreely --init-db
# 4) Generate data encryption keys
./writefreely --gen-keys
# 5) Run
./writefreely
# 6) Check out your site at the URL you specified in the setup process
# 7) There is no Step 7, you're done!
```
For running in production, [see our guide](https://writefreely.org/start#production).
## Packages
WriteFreely is available in these package repositories:
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
* [Nanos Repository](https://repo.ops.city/v2/packages/eyberg/writefreely/show)
## Documentation
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs) &mdash; and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
## Development
Start hacking on WriteFreely with our [developer setup guide](https://writefreely.org/docs/latest/developer/setup). For Docker support, see our [Docker guide](https://writefreely.org/docs/latest/admin/docker).
Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer).
## Docker
Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker).
## Contributing
We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writefreely/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writeas/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
Before contributing anything, please read our [Contributing Guide](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
Before contributing anything, please read our [Contributing Guide](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
## 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).
Licensed under the AGPL.

View file

@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
To report a vulnerability, send an email to security@writefreely.org.

File diff suppressed because it is too large Load diff

View file

@ -1,194 +0,0 @@
package writefreely
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/hashicorp/go-multierror"
"github.com/writeas/impart"
wfimport "github.com/writeas/import"
"github.com/writeas/web-core/log"
)
func viewImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
// Fetch extra user data
p := NewUserPage(app, r, u, "Import Posts", nil)
c, err := app.db.GetCollections(u, app.Config().App.Host)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("unable to fetch collections: %v", err)}
}
d := struct {
*UserPage
Collections *[]Collection
Flashes []template.HTML
Message string
InfoMsg bool
}{
UserPage: p,
Collections: c,
Flashes: []template.HTML{},
}
flashes, _ := getSessionFlashes(app, w, r, nil)
for _, flash := range flashes {
if strings.HasPrefix(flash, "SUCCESS: ") {
d.Message = strings.TrimPrefix(flash, "SUCCESS: ")
} else if strings.HasPrefix(flash, "INFO: ") {
d.Message = strings.TrimPrefix(flash, "INFO: ")
d.InfoMsg = true
} else {
d.Flashes = append(d.Flashes, template.HTML(flash))
}
}
showUserPage(w, "import", d)
return nil
}
func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
// limit 10MB per submission
r.ParseMultipartForm(10 << 20)
collAlias := r.PostFormValue("collection")
coll := &Collection{
ID: 0,
}
var err error
if collAlias != "" {
coll, err = app.db.GetCollection(collAlias)
if err != nil {
log.Error("Unable to get collection for import: %s", err)
return err
}
// Only allow uploading to collection if current user is owner
if coll.OwnerID != u.ID {
err := ErrUnauthorizedGeneral
_ = addSessionFlash(app, w, r, err.Message, nil)
return err
}
coll.hostName = app.cfg.App.Host
}
fileDates := make(map[string]int64)
err = json.Unmarshal([]byte(r.FormValue("fileDates")), &fileDates)
if err != nil {
log.Error("invalid form data for file dates: %v", err)
return impart.HTTPError{http.StatusBadRequest, "form data for file dates was invalid"}
}
files := r.MultipartForm.File["files"]
var fileErrs []error
filesSubmitted := len(files)
var filesImported int
for _, formFile := range files {
fname := ""
ok := func() bool {
file, err := formFile.Open()
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("Unable to read file %s", formFile.Filename))
log.Error("import file: open from form: %v", err)
return false
}
defer file.Close()
tempFile, err := os.CreateTemp("", "post-upload-*.txt")
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
log.Error("import file: create temp file %s: %v", formFile.Filename, err)
return false
}
defer tempFile.Close()
_, err = io.Copy(tempFile, file)
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
log.Error("import file: copy to temp location %s: %v", formFile.Filename, err)
return false
}
info, err := tempFile.Stat()
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
log.Error("import file: stat temp file %s: %v", formFile.Filename, err)
return false
}
fname = info.Name()
return true
}()
if !ok {
continue
}
post, err := wfimport.FromFile(filepath.Join(os.TempDir(), fname))
if err == wfimport.ErrEmptyFile {
// not a real error so don't log
_ = addSessionFlash(app, w, r, fmt.Sprintf("%s was empty, import skipped", formFile.Filename), nil)
continue
} else if err == wfimport.ErrInvalidContentType {
// same as above
_ = addSessionFlash(app, w, r, fmt.Sprintf("%s is not a supported post file", formFile.Filename), nil)
continue
} else if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("failed to read copy of %s", formFile.Filename))
log.Error("import textfile: file to post: %v", err)
continue
}
if collAlias != "" {
post.Collection = collAlias
}
dateTime := time.Unix(fileDates[formFile.Filename], 0)
post.Created = &dateTime
created := post.Created.Format("2006-01-02T15:04:05Z")
submittedPost := SubmittedPost{
Title: &post.Title,
Content: &post.Content,
Font: "norm",
Created: &created,
}
rp, err := app.db.CreatePost(u.ID, coll.ID, &submittedPost)
if err != nil {
fileErrs = append(fileErrs, fmt.Errorf("failed to create post from %s", formFile.Filename))
log.Error("import textfile: create db post: %v", err)
continue
}
// Federate post, if necessary
if app.cfg.App.Federation && coll.ID > 0 {
go federatePost(
app,
&PublicPost{
Post: rp,
Collection: &CollectionObj{
Collection: *coll,
},
},
coll.ID,
false,
)
}
filesImported++
}
if len(fileErrs) != 0 {
_ = addSessionFlash(app, w, r, multierror.ListFormatFunc(fileErrs), nil)
}
if filesImported == filesSubmitted {
verb := "posts"
if filesSubmitted == 1 {
verb = "post"
}
_ = addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: Import complete, %d %s imported.", filesImported, verb), nil)
} else if filesImported > 0 {
_ = addSessionFlash(app, w, r, fmt.Sprintf("INFO: %d of %d posts imported, see details below.", filesImported, filesSubmitted), nil)
}
return impart.HTTPError{http.StatusFound, "/me/import"}
}

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
package writefreely
import (
"testing"
"github.com/writeas/web-core/activitystreams"
)
var actorTestTable = []struct {
Name string
Resp []byte
}{
{
"Context as a string",
[]byte(`{"@context":"https://www.w3.org/ns/activitystreams"}`),
},
{
"Context as a list",
[]byte(`{"@context":["one string", "two strings"]}`),
},
}
func TestUnmarshalActor(t *testing.T) {
for _, tc := range actorTestTable {
actor := activitystreams.Person{}
err := unmarshalActor(tc.Resp, &actor)
if err != nil {
t.Errorf("%s failed with error %s", tc.Name, err)
}
}
}

415
admin.go
View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -13,20 +13,16 @@ package writefreely
import (
"database/sql"
"fmt"
"html/template"
"net/http"
"runtime"
"strconv"
"strings"
"time"
"github.com/gogits/gogs/pkg/tool"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/passgen"
"github.com/writefreely/writefreely/appstats"
"github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/config"
"net/http"
"runtime"
"strconv"
"time"
)
var (
@ -91,71 +87,25 @@ type instanceContent struct {
Updated time.Time
}
type AdminPage struct {
UpdateAvailable bool
}
func NewAdminPage(app *App) *AdminPage {
ap := &AdminPage{}
if app.updates != nil {
ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
}
return ap
}
func (c instanceContent) UpdatedFriendly() template.HTML {
func (c instanceContent) UpdatedFriendly() string {
/*
// TODO: accept a locale in this method and use that for the format
var loc monday.Locale = monday.LocaleEnUS
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
*/
if c.Updated.IsZero() {
return "<em>Never</em>"
}
return template.HTML(c.Updated.Format("January 2, 2006, 3:04 PM"))
return c.Updated.Format("January 2, 2006, 3:04 PM")
}
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Message string
UsersCount, CollectionsCount, PostsCount int64
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
Message: r.FormValue("m"),
}
// Get user stats
p.UsersCount = app.db.GetAllUsersCount()
var err error
p.CollectionsCount, err = app.db.GetTotalCollections()
if err != nil {
return err
}
p.PostsCount, err = app.db.GetTotalPosts()
if err != nil {
return err
}
showUserPage(w, "admin", p)
return nil
}
func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleViewAdminDash(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
updateAppStats()
p := struct {
*UserPage
*AdminPage
SysStatus systemStatus
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
SysStatus: sysStatus,
Config: app.cfg.App,
@ -163,52 +113,28 @@ func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Re
ConfigMessage: r.FormValue("cm"),
}
showUserPage(w, "monitor", p)
showUserPage(w, "admin", p)
return nil
}
func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleViewAdminUsers(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message, ConfigMessage string
}{
UserPage: NewUserPage(app, r, u, "Admin", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
ConfigMessage: r.FormValue("cm"),
}
showUserPage(w, "app-settings", p)
return nil
}
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Flashes []string
Users *[]User
CurPage int
TotalUsers int64
TotalPages []int
}{
UserPage: NewUserPage(app, r, u, "Users", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
UserPage: NewUserPage(app, r, u, "Users", nil),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
p.TotalUsers = app.db.GetAllUsersCount()
ttlPages := (p.TotalUsers - 1) / adminUsersPerPage + 1
ttlPages := p.TotalUsers / adminUsersPerPage
p.TotalPages = []int{}
for i := 1; i <= int(ttlPages); i++ {
p.TotalPages = append(p.TotalPages, i)
@ -231,7 +157,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
return nil
}
func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleViewAdminUser(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
@ -240,39 +166,24 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
User *User
Colls []inspectedCollection
LastPost string
NewPassword string
TotalPosts int64
ClearEmail string
User *User
Colls []inspectedCollection
LastPost string
TotalPosts int64
}{
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
Colls: []inspectedCollection{},
Config: app.cfg.App,
Message: r.FormValue("m"),
Colls: []inspectedCollection{},
}
var err error
p.User, err = app.db.GetUserForAuth(username)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("Could not get user: %v", err)
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
}
flashes, _ := getSessionFlashes(app, w, r, nil)
for _, flash := range flashes {
if strings.HasPrefix(flash, "SUCCESS: ") {
p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ")
p.ClearEmail = p.User.EmailClear(app.keys)
}
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)}
}
p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
@ -284,7 +195,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
}
colls, err := app.db.GetCollections(p.User, app.cfg.App.Host)
colls, err := app.db.GetCollections(p.User)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
}
@ -318,109 +229,17 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
return nil
}
func handleAdminDeleteUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
if !u.IsAdmin() {
return impart.HTTPError{http.StatusForbidden, "Administrator privileges required for this action"}
}
vars := mux.Vars(r)
username := vars["username"]
confirmUsername := r.PostFormValue("confirm-username")
if confirmUsername != username {
return impart.HTTPError{http.StatusBadRequest, "Username was not confirmed"}
}
user, err := app.db.GetUserForAuth(username)
if err == ErrUserNotFound {
return impart.HTTPError{http.StatusNotFound, fmt.Sprintf("User '%s' was not found", username)}
} else if err != nil {
log.Error("get user for deletion: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user with username '%s': %v", username, err)}
}
err = app.db.DeleteAccount(user.ID)
if err != nil {
log.Error("delete user %s: %v", user.Username, err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete user account for '%s': %v", username, err)}
}
_ = addSessionFlash(app, w, r, fmt.Sprintf("User \"%s\" was deleted successfully.", username), nil)
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
user, err := app.db.GetUserForAuth(username)
if err != nil {
log.Error("failed to get user: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)}
}
if user.IsSilenced() {
err = app.db.SetUserStatus(user.ID, UserActive)
} else {
err = app.db.SetUserStatus(user.ID, UserSilenced)
// reset the cache to removed silence user posts
updateTimelineCache(app.timeline, true)
}
if err != nil {
log.Error("toggle user silenced: %v", err)
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
}
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
}
func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
username := vars["username"]
if username == "" {
return impart.HTTPError{http.StatusFound, "/admin/users"}
}
// Generate new random password since none supplied
pass := passgen.NewWordish()
hashedPass, err := auth.HashPass([]byte(pass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
}
userIDVal := r.FormValue("user")
log.Info("ADMIN: Changing user %s password", userIDVal)
id, err := strconv.Atoi(userIDVal)
if err != nil {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)}
}
err = app.db.ChangePassphrase(int64(id), true, "", hashedPass)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
}
log.Info("ADMIN: Successfully changed.")
addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil)
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)}
}
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleViewAdminPages(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Pages []*instanceContent
}{
UserPage: NewUserPage(app, r, u, "Pages", nil),
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
UserPage: NewUserPage(app, r, u, "Pages", nil),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
var err error
@ -430,9 +249,9 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
}
// Add in default pages
var hasAbout, hasContact, hasPrivacy bool
var hasAbout, hasPrivacy bool
for i, c := range p.Pages {
if hasAbout && hasContact && hasPrivacy {
if hasAbout && hasPrivacy {
break
}
if c.ID == "about" {
@ -440,11 +259,6 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
if !c.Title.Valid {
p.Pages[i].Title = defaultAboutTitle(app.cfg)
}
} else if c.ID == "contact" {
hasContact = true
if !c.Title.Valid {
p.Pages[i].Title = defaultContactTitle()
}
} else if c.ID == "privacy" {
hasPrivacy = true
if !c.Title.Valid {
@ -460,13 +274,6 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
Updated: defaultPageUpdatedTime,
})
}
if !hasContact {
p.Pages = append(p.Pages, &instanceContent{
ID: "contact",
Title: defaultContactTitle(),
Content: defaultContactPage(app),
})
}
if !hasPrivacy {
p.Pages = append(p.Pages, &instanceContent{
ID: "privacy",
@ -480,7 +287,7 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
return nil
}
func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleViewAdminPage(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
if slug == "" {
@ -489,35 +296,21 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
p := struct {
*UserPage
*AdminPage
Config config.AppCfg
Message string
Banner *instanceContent
Content *instanceContent
}{
AdminPage: NewAdminPage(app),
Config: app.cfg.App,
Message: r.FormValue("m"),
Config: app.cfg.App,
Message: r.FormValue("m"),
}
var err error
// Get pre-defined pages, or select slug
if slug == "about" {
p.Content, err = getAboutPage(app)
} else if slug == "contact" {
p.Content, err = getContactPage(app)
} else if slug == "privacy" {
p.Content, err = getPrivacyPage(app)
} else if slug == "landing" {
p.Banner, err = getLandingBanner(app)
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
}
p.Content, err = getLandingBody(app)
p.Content.ID = "landing"
} else if slug == "reader" {
p.Content, err = getReaderSection(app)
} else {
p.Content, err = app.db.GetDynamicContent(slug)
}
@ -536,114 +329,96 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
return nil
}
func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleAdminUpdateSite(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
id := vars["page"]
// Validate
if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" {
if id != "about" && id != "privacy" {
return impart.HTTPError{http.StatusNotFound, "No such page."}
}
var err error
// Update page
m := ""
if id == "landing" {
// Handle special landing page
err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section")
if err != nil {
m = "?m=" + err.Error()
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
} else if id == "reader" {
// Update sections with titles
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section")
} else {
// Update page
err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
}
err := app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
if err != nil {
m = "?m=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}
func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error {
apper.App().cfg.App.SiteName = r.FormValue("site_name")
apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
apper.App().cfg.App.Landing = r.FormValue("landing")
apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
apper.App().cfg.App.OpenDeletion = r.FormValue("open_deletion") == "on"
func handleAdminUpdateConfig(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
app.cfg.App.SiteName = r.FormValue("site_name")
app.cfg.App.SiteDesc = r.FormValue("site_desc")
app.cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
mul, err := strconv.Atoi(r.FormValue("min_username_len"))
if err == nil {
apper.App().cfg.App.MinUsernameLen = mul
app.cfg.App.MinUsernameLen = mul
}
mb, err := strconv.Atoi(r.FormValue("max_blogs"))
if err == nil {
apper.App().cfg.App.MaxBlogs = mb
app.cfg.App.MaxBlogs = mb
}
apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on"
apper.App().cfg.App.Private = r.FormValue("private") == "on"
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
app.cfg.App.Federation = r.FormValue("federation") == "on"
app.cfg.App.PublicStats = r.FormValue("public_stats") == "on"
app.cfg.App.Private = r.FormValue("private") == "on"
app.cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
if app.cfg.App.LocalTimeline && app.timeline == nil {
log.Info("Initializing local timeline...")
initLocalTimeline(apper.App())
initLocalTimeline(app)
}
apper.App().cfg.App.UserInvites = r.FormValue("user_invites")
if apper.App().cfg.App.UserInvites == "none" {
apper.App().cfg.App.UserInvites = ""
app.cfg.App.UserInvites = r.FormValue("user_invites")
if app.cfg.App.UserInvites == "none" {
app.cfg.App.UserInvites = ""
}
apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")
m := "?cm=Configuration+saved."
err = apper.SaveConfig(apper.App().cfg)
err = config.Save(app.cfg, app.cfgFile)
if err != nil {
m = "?cm=" + err.Error()
}
return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"}
}
func updateAppStats() {
sysStatus.Uptime = appstats.TimeSincePro(appStartTime)
sysStatus.Uptime = tool.TimeSincePro(appStartTime)
m := new(runtime.MemStats)
runtime.ReadMemStats(m)
sysStatus.NumGoroutine = runtime.NumGoroutine()
sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc))
sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = appstats.FileSize(int64(m.Sys))
sysStatus.MemAllocated = tool.FileSize(int64(m.Alloc))
sysStatus.MemTotal = tool.FileSize(int64(m.TotalAlloc))
sysStatus.MemSys = tool.FileSize(int64(m.Sys))
sysStatus.Lookups = m.Lookups
sysStatus.MemMallocs = m.Mallocs
sysStatus.MemFrees = m.Frees
sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased))
sysStatus.HeapAlloc = tool.FileSize(int64(m.HeapAlloc))
sysStatus.HeapSys = tool.FileSize(int64(m.HeapSys))
sysStatus.HeapIdle = tool.FileSize(int64(m.HeapIdle))
sysStatus.HeapInuse = tool.FileSize(int64(m.HeapInuse))
sysStatus.HeapReleased = tool.FileSize(int64(m.HeapReleased))
sysStatus.HeapObjects = m.HeapObjects
sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse))
sysStatus.StackSys = appstats.FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = appstats.FileSize(int64(m.GCSys))
sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys))
sysStatus.StackInuse = tool.FileSize(int64(m.StackInuse))
sysStatus.StackSys = tool.FileSize(int64(m.StackSys))
sysStatus.MSpanInuse = tool.FileSize(int64(m.MSpanInuse))
sysStatus.MSpanSys = tool.FileSize(int64(m.MSpanSys))
sysStatus.MCacheInuse = tool.FileSize(int64(m.MCacheInuse))
sysStatus.MCacheSys = tool.FileSize(int64(m.MCacheSys))
sysStatus.BuckHashSys = tool.FileSize(int64(m.BuckHashSys))
sysStatus.GCSys = tool.FileSize(int64(m.GCSys))
sysStatus.OtherSys = tool.FileSize(int64(m.OtherSys))
sysStatus.NextGC = appstats.FileSize(int64(m.NextGC))
sysStatus.NextGC = tool.FileSize(int64(m.NextGC))
sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
sysStatus.NumGC = m.NumGC
}
func adminResetPassword(app *App, u *User, newPass string) error {
func adminResetPassword(app *app, u *User, newPass string) error {
hashedPass, err := auth.HashPass([]byte(newPass))
if err != nil {
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
@ -655,39 +430,3 @@ func adminResetPassword(app *App, u *User, newPass string) error {
}
return nil
}
func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
check := r.URL.Query().Get("check")
if check == "now" && app.cfg.App.UpdateChecks {
app.updates.CheckNow()
}
p := struct {
*UserPage
*AdminPage
CurReleaseNotesURL string
LastChecked string
LastChecked8601 string
LatestVersion string
LatestReleaseURL string
LatestReleaseNotesURL string
CheckFailed bool
}{
UserPage: NewUserPage(app, r, u, "Updates", nil),
AdminPage: NewAdminPage(app),
}
p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
if app.cfg.App.UpdateChecks {
p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
p.LatestVersion = app.updates.LatestVersion()
p.LatestReleaseURL = app.updates.ReleaseURL()
p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
p.UpdateAvailable = app.updates.AreAvailable()
p.CheckFailed = app.updates.checkError != nil
}
showUserPage(w, "app-updates", p)
return nil
}

990
app.go

File diff suppressed because it is too large Load diff

View file

@ -1,128 +0,0 @@
// Copyright 2014-2018 The Gogs Authors. All rights reserved.
// Use of this source code is governed by a MIT-style license that can be
// found in the LICENSE file of the Gogs project (github.com/gogs/gogs).
package appstats
import (
"fmt"
"math"
"strings"
"time"
)
// Borrowed from github.com/gogs/gogs/pkg/tool
// Seconds-based time units
const (
Minute = 60
Hour = 60 * Minute
Day = 24 * Hour
Week = 7 * Day
Month = 30 * Day
Year = 12 * Month
)
func computeTimeDiff(diff int64) (int64, string) {
diffStr := ""
switch {
case diff <= 0:
diff = 0
diffStr = "now"
case diff < 2:
diff = 0
diffStr = "1 second"
case diff < 1*Minute:
diffStr = fmt.Sprintf("%d seconds", diff)
diff = 0
case diff < 2*Minute:
diff -= 1 * Minute
diffStr = "1 minute"
case diff < 1*Hour:
diffStr = fmt.Sprintf("%d minutes", diff/Minute)
diff -= diff / Minute * Minute
case diff < 2*Hour:
diff -= 1 * Hour
diffStr = "1 hour"
case diff < 1*Day:
diffStr = fmt.Sprintf("%d hours", diff/Hour)
diff -= diff / Hour * Hour
case diff < 2*Day:
diff -= 1 * Day
diffStr = "1 day"
case diff < 1*Week:
diffStr = fmt.Sprintf("%d days", diff/Day)
diff -= diff / Day * Day
case diff < 2*Week:
diff -= 1 * Week
diffStr = "1 week"
case diff < 1*Month:
diffStr = fmt.Sprintf("%d weeks", diff/Week)
diff -= diff / Week * Week
case diff < 2*Month:
diff -= 1 * Month
diffStr = "1 month"
case diff < 1*Year:
diffStr = fmt.Sprintf("%d months", diff/Month)
diff -= diff / Month * Month
case diff < 2*Year:
diff -= 1 * Year
diffStr = "1 year"
default:
diffStr = fmt.Sprintf("%d years", diff/Year)
diff = 0
}
return diff, diffStr
}
// TimeSincePro calculates the time interval and generate full user-friendly string.
func TimeSincePro(then time.Time) string {
now := time.Now()
diff := now.Unix() - then.Unix()
if then.After(now) {
return "future"
}
var timeStr, diffStr string
for {
if diff == 0 {
break
}
diff, diffStr = computeTimeDiff(diff)
timeStr += ", " + diffStr
}
return strings.TrimPrefix(timeStr, ", ")
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}
func humanateBytes(s uint64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := float64(s) / math.Pow(base, math.Floor(e))
f := "%.0f"
if val < 10 {
f = "%.1f"
}
return fmt.Sprintf(f+" %s", val, suffix)
}
// FileSize calculates the file size and generate user-friendly string.
func FileSize(s int64) string {
sizes := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
return humanateBytes(uint64(s), 1024, sizes)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -11,8 +11,7 @@
package author
import (
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/config"
"github.com/writeas/writefreely/config"
"os"
"path/filepath"
"regexp"
@ -66,7 +65,6 @@ var reservedUsernames = map[string]bool{
"metadata": true,
"new": true,
"news": true,
"oauth": true,
"post": true,
"posts": true,
"privacy": true,
@ -114,17 +112,10 @@ func IsValidUsername(cfg *config.Config, username string) bool {
// Username is invalid if page with the same name exists. So traverse
// available pages, adding them to reservedUsernames map that'll be checked
// later.
err := filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
if err != nil {
return err
}
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
reservedUsernames[i.Name()] = true
return nil
})
if err != nil {
log.Error("[IMPORTANT WARNING]: Could not determine IsValidUsername! %s", err)
return false
}
// Username is invalid if it is reserved!
if _, reserved := reservedUsernames[username]; reserved {

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*

View file

@ -1,60 +0,0 @@
/*
* 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 main
import (
"github.com/urfave/cli/v2"
"github.com/writefreely/writefreely"
)
var (
cmdConfig cli.Command = cli.Command{
Name: "config",
Usage: "config management tools",
Subcommands: []*cli.Command{
&cmdConfigGenerate,
&cmdConfigInteractive,
},
}
cmdConfigGenerate cli.Command = cli.Command{
Name: "generate",
Aliases: []string{"gen"},
Usage: "Generate a basic configuration",
Action: genConfigAction,
}
cmdConfigInteractive cli.Command = cli.Command{
Name: "start",
Usage: "Interactive configuration process",
Action: interactiveConfigAction,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "sections",
Value: "server db app",
Usage: "Which sections of the configuration to go through\n" +
"valid values of sections flag are any combination of 'server', 'db' and 'app' \n" +
"example: writefreely config start --sections \"db app\"",
},
},
}
)
func genConfigAction(c *cli.Context) error {
app := writefreely.NewApp(c.String("c"))
return writefreely.CreateConfig(app)
}
func interactiveConfigAction(c *cli.Context) error {
app := writefreely.NewApp(c.String("c"))
writefreely.DoConfig(app, c.String("sections"))
return nil
}

View file

@ -1,49 +0,0 @@
/*
* 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 main
import (
"github.com/urfave/cli/v2"
"github.com/writefreely/writefreely"
)
var (
cmdDB cli.Command = cli.Command{
Name: "db",
Usage: "db management tools",
Subcommands: []*cli.Command{
&cmdDBInit,
&cmdDBMigrate,
},
}
cmdDBInit cli.Command = cli.Command{
Name: "init",
Usage: "Initialize Database",
Action: initDBAction,
}
cmdDBMigrate cli.Command = cli.Command{
Name: "migrate",
Usage: "Migrate Database",
Action: migrateDBAction,
}
)
func initDBAction(c *cli.Context) error {
app := writefreely.NewApp(c.String("c"))
return writefreely.CreateSchema(app)
}
func migrateDBAction(c *cli.Context) error {
app := writefreely.NewApp(c.String("c"))
return writefreely.Migrate(app)
}

View file

@ -1,38 +0,0 @@
/*
* 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 main
import (
"github.com/urfave/cli/v2"
"github.com/writefreely/writefreely"
)
var (
cmdKeys cli.Command = cli.Command{
Name: "keys",
Usage: "key management tools",
Subcommands: []*cli.Command{
&cmdGenerateKeys,
},
}
cmdGenerateKeys cli.Command = cli.Command{
Name: "generate",
Aliases: []string{"gen"},
Usage: "Generate encryption and authentication keys",
Action: genKeysAction,
}
)
func genKeysAction(c *cli.Context) error {
app := writefreely.NewApp(c.String("c"))
return writefreely.GenerateKeyFiles(app)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -11,173 +11,9 @@
package main
import (
"fmt"
"os"
"strings"
"github.com/gorilla/mux"
"github.com/urfave/cli/v2"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely"
"github.com/writeas/writefreely"
)
func main() {
cli.VersionPrinter = func(c *cli.Context) {
fmt.Printf("%s\n", c.App.Version)
}
app := &cli.App{
Name: "WriteFreely",
Usage: "A beautifully pared-down blogging platform",
Version: writefreely.FormatVersion(),
Action: legacyActions, // legacy due to use of flags for switching actions
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "create-config",
Value: false,
Usage: "Generate a basic configuration",
Hidden: true,
},
&cli.BoolFlag{
Name: "config",
Value: false,
Usage: "Interactive configuration process",
Hidden: true,
},
&cli.StringFlag{
Name: "sections",
Value: "server db app",
Usage: "Which sections of the configuration to go through (requires --config)\n" +
"valid values are any combination of 'server', 'db' and 'app' \n" +
"example: writefreely --config --sections \"db app\"",
Hidden: true,
},
&cli.BoolFlag{
Name: "gen-keys",
Value: false,
Usage: "Generate encryption and authentication keys",
Hidden: true,
},
&cli.BoolFlag{
Name: "init-db",
Value: false,
Usage: "Initialize app database",
Hidden: true,
},
&cli.BoolFlag{
Name: "migrate",
Value: false,
Usage: "Migrate the database",
Hidden: true,
},
&cli.StringFlag{
Name: "create-admin",
Usage: "Create an admin with the given username:password",
Hidden: true,
},
&cli.StringFlag{
Name: "create-user",
Usage: "Create a regular user with the given username:password",
Hidden: true,
},
&cli.StringFlag{
Name: "delete-user",
Usage: "Delete a user with the given username",
Hidden: true,
},
&cli.StringFlag{
Name: "reset-pass",
Usage: "Reset the given user's password",
Hidden: true,
},
}, // legacy flags (set to hidden to eventually switch to bash-complete compatible format)
}
defaultFlags := []cli.Flag{
&cli.StringFlag{
Name: "c",
Value: "config.ini",
Usage: "Load configuration from `FILE`",
},
&cli.BoolFlag{
Name: "debug",
Value: false,
Usage: "Enables debug logging",
},
}
app.Flags = append(app.Flags, defaultFlags...)
app.Commands = []*cli.Command{
&cmdUser,
&cmdDB,
&cmdConfig,
&cmdKeys,
&cmdServe,
}
err := app.Run(os.Args)
if err != nil {
log.Error(err.Error())
os.Exit(1)
}
}
func legacyActions(c *cli.Context) error {
app := writefreely.NewApp(c.String("c"))
switch true {
case c.IsSet("create-config"):
return writefreely.CreateConfig(app)
case c.IsSet("config"):
writefreely.DoConfig(app, c.String("sections"))
return nil
case c.IsSet("gen-keys"):
return writefreely.GenerateKeyFiles(app)
case c.IsSet("init-db"):
return writefreely.CreateSchema(app)
case c.IsSet("migrate"):
return writefreely.Migrate(app)
case c.IsSet("create-admin"):
username, password, err := parseCredentials(c.String("create-admin"))
if err != nil {
return err
}
return writefreely.CreateUser(app, username, password, true)
case c.IsSet("create-user"):
username, password, err := parseCredentials(c.String("create-user"))
if err != nil {
return err
}
return writefreely.CreateUser(app, username, password, false)
case c.IsSet("delete-user"):
return writefreely.DoDeleteAccount(app, c.String("delete-user"))
case c.IsSet("reset-pass"):
return writefreely.ResetPassword(app, c.String("reset-pass"))
}
// Initialize the application
var err error
log.Info("Starting %s...", writefreely.FormatVersion())
app, err = writefreely.Initialize(app, c.Bool("debug"))
if err != nil {
return err
}
// Set app routes
r := mux.NewRouter()
writefreely.InitRoutes(app, r)
app.InitStaticRoutes(r)
// Serve the application
writefreely.Serve(app, r)
return nil
}
func parseCredentials(credentialString string) (string, string, error) {
creds := strings.Split(credentialString, ":")
if len(creds) != 2 {
return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
}
return creds[0], creds[1], nil
writefreely.Serve()
}

View file

@ -1,96 +0,0 @@
/*
* 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 main
import (
"fmt"
"github.com/urfave/cli/v2"
"github.com/writefreely/writefreely"
)
var (
cmdUser cli.Command = cli.Command{
Name: "user",
Usage: "user management tools",
Subcommands: []*cli.Command{
&cmdAddUser,
&cmdDelUser,
&cmdResetPass,
// TODO: possibly add a user list command
},
}
cmdAddUser cli.Command = cli.Command{
Name: "create",
Usage: "Add new user",
Aliases: []string{"a", "add"},
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "admin",
Value: false,
Usage: "Create admin user",
},
},
Action: addUserAction,
}
cmdDelUser cli.Command = cli.Command{
Name: "delete",
Usage: "Delete user",
Aliases: []string{"del", "d"},
Action: delUserAction,
}
cmdResetPass cli.Command = cli.Command{
Name: "reset-pass",
Usage: "Reset user's password",
Aliases: []string{"resetpass", "reset"},
Action: resetPassAction,
}
)
func addUserAction(c *cli.Context) error {
credentials := ""
if c.NArg() > 0 {
credentials = c.Args().Get(0)
} else {
return fmt.Errorf("No user passed. Example: writefreely user add [USER]:[PASSWORD]")
}
username, password, err := parseCredentials(credentials)
if err != nil {
return err
}
app := writefreely.NewApp(c.String("c"))
return writefreely.CreateUser(app, username, password, c.Bool("admin"))
}
func delUserAction(c *cli.Context) error {
username := ""
if c.NArg() > 0 {
username = c.Args().Get(0)
} else {
return fmt.Errorf("No user passed. Example: writefreely user delete [USER]")
}
app := writefreely.NewApp(c.String("c"))
return writefreely.DoDeleteAccount(app, username)
}
func resetPassAction(c *cli.Context) error {
username := ""
if c.NArg() > 0 {
username = c.Args().Get(0)
} else {
return fmt.Errorf("No user passed. Example: writefreely user reset-pass [USER]")
}
app := writefreely.NewApp(c.String("c"))
return writefreely.ResetPassword(app, username)
}

View file

@ -1,48 +0,0 @@
/*
* 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 main
import (
"github.com/gorilla/mux"
"github.com/urfave/cli/v2"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely"
)
var (
cmdServe cli.Command = cli.Command{
Name: "serve",
Aliases: []string{"web"},
Usage: "Run web application",
Action: serveAction,
}
)
func serveAction(c *cli.Context) error {
// Initialize the application
app := writefreely.NewApp(c.String("c"))
var err error
log.Info("Starting %s...", writefreely.FormatVersion())
app, err = writefreely.Initialize(app, c.Bool("debug"))
if err != nil {
return err
}
// Set app routes
r := mux.NewRouter()
writefreely.InitRoutes(app, r)
app.InitStaticRoutes(r)
// Serve the application
writefreely.Serve(app, r)
return nil
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2022 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -24,26 +24,14 @@ import (
"unicode"
"github.com/gorilla/mux"
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/impart"
"github.com/writeas/web-core/activitystreams"
"github.com/writeas/web-core/auth"
"github.com/writeas/web-core/bots"
"github.com/writeas/web-core/i18n"
"github.com/writeas/web-core/log"
"github.com/writeas/web-core/posts"
"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
waposts "github.com/writeas/web-core/posts"
"github.com/writeas/writefreely/author"
"github.com/writeas/writefreely/page"
)
type (
@ -58,7 +46,6 @@ type (
Language string `schema:"lang" json:"lang,omitempty"`
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
Signature string `datastore:"post_signature" schema:"signature" json:"-"`
Public bool `datastore:"public" json:"public"`
Visibility collVisibility `datastore:"private" json:"-"`
Format string `datastore:"format" json:"format,omitempty"`
@ -67,36 +54,22 @@ type (
PublicOwner bool `datastore:"public_owner" json:"-"`
URL string `json:"url,omitempty"`
Monetization string `json:"monetization_pointer,omitempty"`
Verification string `json:"verification_link"`
db *datastore
hostName string
db *datastore
}
CollectionObj struct {
Collection
TotalPosts int `json:"total_posts"`
Owner *User `json:"owner,omitempty"`
Posts *[]PublicPost `json:"posts,omitempty"`
Format *CollectionFormat
}
DisplayCollection struct {
*CollectionObj
Prefix string
NavSuffix string
IsTopLevel bool
CurrentPage int
TotalPages int
Silenced bool
Format *CollectionFormat
}
CollectionNav struct {
*Collection
Path string
SingleUser bool
CanPost bool
}
SubmittedCollection struct {
// Data used for updating a given collection
ID int64
@ -107,21 +80,16 @@ 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 *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"`
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"`
Visibility *int `schema:"visibility" json:"public"`
Format *sql.NullString `schema:"format" json:"format"`
}
CollectionFormat struct {
Format string
@ -134,8 +102,6 @@ type (
// User-related fields
isCollOwner bool
isAuthorized bool
}
)
@ -159,21 +125,6 @@ const (
CollProtected
)
var collVisibilityStrings = map[string]collVisibility{
"unlisted": CollUnlisted,
"public": CollPublic,
"private": CollPrivate,
"protected": CollProtected,
}
func defaultVisibility(cfg *config.Config) collVisibility {
vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility]
if !ok {
vis = CollUnlisted
}
return vis
}
func (cf *CollectionFormat) Ascending() bool {
return cf.Format == "novel"
}
@ -206,11 +157,6 @@ func (c *Collection) NewFormat() *CollectionFormat {
return cf
}
func (c *Collection) IsInstanceColl() bool {
ur, _ := url.Parse(c.hostName)
return c.Alias == ur.Host
}
func (c *Collection) IsUnlisted() bool {
return c.Visibility == 0
}
@ -260,37 +206,29 @@ func (c *Collection) DisplayCanonicalURL() string {
if p == "/" {
p = ""
}
d := u.Hostname()
d, _ = idna.ToUnicode(d)
return d + p
return u.Hostname() + p
}
// RedirectingCanonicalURL returns the fully-qualified canonical URL for the Collection, with a trailing slash. The
// hostName field needs to be populated for this to work correctly.
func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
if c.hostName == "" {
// If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail
log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writefreely/writefreely/issues/new?template=bug_report.md")
}
if isSingleUser {
return c.hostName + "/"
return hostName + "/"
}
return fmt.Sprintf("%s/%s/", c.hostName, c.Alias)
return fmt.Sprintf("%s/%s/", hostName, c.Alias)
}
// PrevPageURL provides a full URL for the previous page of collection posts,
// returning a /page/N result for pages >1
func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) string {
func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
u := ""
if n == 2 {
// Previous page is 1; no need for /page/ prefix
if prefix == "" {
u = navSuffix + "/"
u = "/"
}
// Else leave off trailing slash
} else {
u = fmt.Sprintf("%s/page/%d", navSuffix, n-1)
u = fmt.Sprintf("/page/%d", n-1)
}
if tl {
@ -300,12 +238,11 @@ func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) strin
}
// NextPageURL provides a full URL for the next page of collection posts
func (c *Collection) NextPageURL(prefix, navSuffix string, n int, tl bool) string {
func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
if tl {
return fmt.Sprintf("%s/page/%d", navSuffix, n+1)
return fmt.Sprintf("/page/%d", n+1)
}
return fmt.Sprintf("/%s%s%s/page/%d", prefix, c.Alias, navSuffix, n+1)
return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
}
func (c *Collection) DisplayTitle() string {
@ -363,11 +300,11 @@ func (c *Collection) AvatarURL() string {
if !isAvatarChar(fl) {
return ""
}
return c.hostName + "/img/avatars/" + fl + ".png"
return hostName + "/img/avatars/" + fl + ".png"
}
func (c *Collection) FederatedAPIBase() string {
return c.hostName + "/"
return hostName + "/"
}
func (c *Collection) FederatedAccount() string {
@ -379,53 +316,8 @@ 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 ""
}
return strings.Replace(c.Monetization, "$", "https://", 1)
}
// DisplayDescription returns the description with rendered Markdown and HTML.
func (c *Collection) DisplayDescription() *template.HTML {
if c.Description == "" {
s := template.HTML("")
return &s
}
t := template.HTML(posts.ApplyBasicAccessibleMarkdown([]byte(c.Description)))
return &t
}
// PlainDescription returns the description with all Markdown and HTML removed.
func (c *Collection) PlainDescription() string {
if c.Description == "" {
return ""
}
desc := stripHTMLWithoutEscaping(c.Description)
desc = stripmd.Strip(desc)
return desc
}
func (c CollectionPage) DisplayMonetization() string {
return displayMonetization(c.Monetization, c.Alias)
}
func (c *DisplayCollection) Direction() string {
if c.Language == "" {
return "auto"
}
if i18n.LangIsRTL(c.Language) {
return "rtl"
}
return "ltr"
}
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
func newCollection(app *app, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
alias := r.FormValue("alias")
title := r.FormValue("title")
@ -465,41 +357,36 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
}
var userID int64
var err error
if reqJSON && !c.Web {
accessToken = r.Header.Get("Authorization")
if accessToken == "" {
return ErrNoAccessToken
}
userID = app.db.GetUserID(accessToken)
if userID == -1 {
return ErrBadAccessToken
}
} else {
u = getUserSession(app, r)
if u == nil {
return ErrNotLoggedIn
}
userID = u.ID
}
silenced, err := app.db.IsUserSilenced(userID)
if err != nil {
log.Error("new collection: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrUserSilenced
}
if !author.IsValidUsername(app.cfg, c.Alias) {
return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
}
coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID)
if err != nil {
// TODO: handle this
return err
var coll *Collection
var err error
if accessToken != "" {
coll, err = app.db.CreateCollectionFromToken(c.Alias, c.Title, accessToken)
if err != nil {
// TODO: handle this
return err
}
} else {
coll, err = app.db.CreateCollection(c.Alias, c.Title, u.ID)
if err != nil {
// TODO: handle this
return err
}
}
res := &CollectionObj{Collection: *coll}
@ -512,7 +399,7 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
return impart.HTTPError{http.StatusFound, redirectTo}
}
func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) {
func apiCheckCollectionPermissions(app *app, r *http.Request, c *Collection) (int64, error) {
accessToken := r.Header.Get("Authorization")
var userID int64 = -1
if accessToken != "" {
@ -532,8 +419,9 @@ func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (in
}
// fetchCollection handles the API endpoint for retrieving collection data.
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
if IsActivityPubRequest(r) {
func fetchCollection(app *app, w http.ResponseWriter, r *http.Request) error {
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/activity+json") {
return handleFetchCollectionActivities(app, w, r)
}
@ -546,10 +434,8 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
// Redirect users who aren't requesting JSON
reqJSON := IsJSON(r)
reqJSON := IsJSON(r.Header.Get("Content-Type"))
if !reqJSON {
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
}
@ -572,7 +458,6 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
res.Owner = u
}
}
// TODO: check status for silenced
app.db.GetPostsCount(res, isCollOwner)
// Strip non-public information
res.Collection.ForPublic()
@ -582,7 +467,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
// fetchCollectionPosts handles an API endpoint for retrieving a collection's
// posts.
func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error {
func fetchCollectionPosts(app *app, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
alias := vars["alias"]
@ -590,7 +475,6 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
if err != nil {
return err
}
c.hostName = app.cfg.App.Host
// Check permissions
userID, err := apiCheckCollectionPermissions(app, r, c)
@ -608,11 +492,11 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
}
}
ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false, "")
posts, err := app.db.GetPosts(c, page, isCollOwner, false, false)
if err != nil {
return err
}
coll := &CollectionObj{Collection: *c, Posts: ps}
coll := &CollectionObj{Collection: *c, Posts: posts}
app.db.GetPostsCount(coll, isCollOwner)
// Strip non-public information
coll.Collection.ForPublic()
@ -620,7 +504,7 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
// Transform post bodies if needed
if r.FormValue("body") == "html" {
for _, p := range *coll.Posts {
p.Content = posts.ApplyMarkdown([]byte(p.Content))
p.Content = waposts.ApplyMarkdown([]byte(p.Content))
}
}
@ -633,52 +517,10 @@ type CollectionPage struct {
IsCustomDomain bool
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
// Helper field for Chorus mode
CollAlias string
}
type TagCollectionPage struct {
CollectionPage
Tag string
}
func (tcp TagCollectionPage) PrevPageURL(prefix string, n int, tl bool) string {
u := fmt.Sprintf("/tag:%s", tcp.Tag)
if n > 2 {
u += fmt.Sprintf("/page/%d", n-1)
}
if tl {
return u
}
return "/" + prefix + tcp.Alias + u
}
func (tcp TagCollectionPage) NextPageURL(prefix string, n int, tl bool) string {
if tl {
return fmt.Sprintf("/tag:%s/page/%d", tcp.Tag, n+1)
}
return fmt.Sprintf("/%s%s/tag:%s/page/%d", prefix, tcp.Alias, tcp.Tag, n+1)
}
func NewCollectionObj(c *Collection) *CollectionObj {
return &CollectionObj{
Collection: *c,
Format: c.NewFormat(),
}
}
func (c *CollectionObj) ScriptDisplay() template.JS {
@ -721,7 +563,7 @@ func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.
// domain that doesn't yet have a collection associated, or if a collection
// requires a password. In either case, this will return nil, nil -- thus both
// values should ALWAYS be checked to determine whether or not to continue.
func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
func processCollectionPermissions(app *app, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
// Display collection if this is a collection
var c *Collection
var err error
@ -758,7 +600,6 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
}
return nil, err
}
c.hostName = app.cfg.App.Host
// Update CollectionRequest to reflect owner status
cr.isCollOwner = u != nil && u.ID == c.OwnerID
@ -773,20 +614,10 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
uname = u.Username
}
// TODO: move this to all permission checks?
suspended, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("process protected collection permissions: %v", err)
return nil, err
}
if suspended {
return nil, ErrCollectionNotFound
}
// See if we've authorized this collection
cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r)
authd := isAuthorizedForCollection(app, c.Alias, r)
if !cr.isAuthorized {
if !authd {
p := struct {
page.StaticPage
*CollectionObj
@ -823,37 +654,35 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
return c, nil
}
func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
func checkUserForCollection(app *app, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
u := getUserSession(app, r)
return u, nil
}
func newDisplayCollection(c *Collection, cr *collectionReq, page int) (*DisplayCollection, error) {
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
coll := &DisplayCollection{
CollectionObj: NewCollectionObj(c),
CollectionObj: &CollectionObj{Collection: *c},
CurrentPage: page,
Prefix: cr.prefix,
IsTopLevel: isSingleUser,
Format: c.NewFormat(),
}
err := c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
if err != nil {
return nil, err
}
return coll, nil
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
return coll
}
// getCollectionPage returns the collection page as an int. If the parsed page value is not
// greater than 0 then the default value of 1 is returned.
func getCollectionPage(vars map[string]string) int {
if p, _ := strconv.Atoi(vars["page"]); p > 0 {
return p
page := 1
var p int
p, _ = strconv.Atoi(vars["page"])
if p > 0 {
page = p
}
return 1
return page
}
// handleViewCollection displays the requested Collection
func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error {
func handleViewCollection(app *app, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
cr := &collectionReq{}
@ -873,40 +702,19 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
if c == nil || err != nil {
return err
}
c.hostName = app.cfg.App.Host
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view collection: %v", err)
return ErrInternalGeneral
}
// Serve ActivityStreams data now, if requested
if IsActivityPubRequest(r) {
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
ac := c.PersonObject()
setCacheControl(w, apCacheTime)
ac.Context = []interface{}{activitystreams.Namespace}
return impart.RenderActivityJSON(w, ac, http.StatusOK)
}
// Fetch extra data about the Collection
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
coll, err := newDisplayCollection(c, cr, page)
if err != nil {
return err
}
coll := newDisplayCollection(c, cr, page)
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)))
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
if coll.TotalPages > 0 && page > coll.TotalPages {
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
if !app.cfg.App.SingleUser {
@ -915,43 +723,32 @@ 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(c, page, cr.isCollOwner, false, false)
// Serve collection
displayPage := CollectionPage{
DisplayCollection: coll,
IsCollLoggedIn: cr.isAuthorized,
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
displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
pubColls, err := app.db.GetPublishableCollections(owner)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
displayPage.Collections = pubColls
}
}
isOwner := owner != nil
if !isOwner {
if owner == nil {
// Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil {
@ -959,26 +756,14 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
log.Error("Error getting user for collection: %v", err)
}
}
if !isOwner && silenced {
return ErrCollectionNotFound
}
displayPage.Silenced = isOwner && silenced
displayPage.Owner = owner
coll.Owner = displayPage.Owner
// Add more data
// TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj)
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)
err = templates["collection"].ExecuteTemplate(w, "collection", displayPage)
if err != nil {
log.Error("Unable to render collection index: %v", err)
}
@ -1003,24 +788,7 @@ 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"]
remoteUser, err := app.db.GetProfilePageFromHandle(app, handle)
if err != nil || remoteUser == "" {
log.Error("Couldn't find user %s: %v", handle, err)
return ErrRemoteUserNotFound
}
return impart.HTTPError{Status: http.StatusFound, Message: remoteUser}
}
func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
func handleViewCollectionTag(app *app, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
tag := vars["tag"]
@ -1042,31 +810,18 @@ 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 {
return err
}
ttlPosts := len(taggedPostIDs)
pagePosts := coll.Format.PostsPerPage()
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
if coll.TotalPages > 0 && page > coll.TotalPages {
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
if !app.cfg.App.SingleUser {
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
}
return impart.HTTPError{http.StatusFound, redirURL}
}
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
coll.Posts, _ = app.db.GetPostsTagged(c, tag, page, cr.isCollOwner)
if coll.Posts != nil && len(*coll.Posts) == 0 {
return ErrCollectionPageNotFound
}
// Serve collection
displayPage := TagCollectionPage{
displayPage := struct {
CollectionPage
Tag string
}{
CollectionPage: CollectionPage{
DisplayCollection: coll,
StaticPage: pageForReq(app, r),
@ -1083,32 +838,26 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
owner = u
displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
pubColls, err := app.db.GetPublishableCollections(owner)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
displayPage.Collections = pubColls
}
}
isOwner := owner != nil
if !isOwner {
if owner == nil {
// Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
if owner.IsSilenced() {
return ErrCollectionNotFound
}
}
displayPage.Silenced = owner != nil && owner.IsSilenced()
displayPage.Owner = owner
coll.Owner = displayPage.Owner
// Add more data
// TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj)
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
if err != nil {
@ -1118,112 +867,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
return nil
}
func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
lang := vars["lang"]
cr := &collectionReq{}
err := processCollectionRequest(cr, vars, w, r)
if err != nil {
return err
}
u, err := checkUserForCollection(app, cr, r, false)
if err != nil {
return err
}
page := getCollectionPage(vars)
c, err := processCollectionPermissions(app, cr, u, w, r)
if c == nil || err != nil {
return err
}
coll, _ := newDisplayCollection(c, cr, page)
coll.Language = lang
coll.NavSuffix = fmt.Sprintf("/lang:%s", lang)
ttlPosts, err := app.db.GetCollLangTotalPosts(coll.ID, lang)
if err != nil {
log.Error("Unable to getCollLangTotalPosts: %s", err)
}
pagePosts := coll.Format.PostsPerPage()
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
if coll.TotalPages > 0 && page > coll.TotalPages {
redirURL := fmt.Sprintf("/lang:%s/page/%d", lang, coll.TotalPages)
if !app.cfg.App.SingleUser {
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
}
return impart.HTTPError{http.StatusFound, redirURL}
}
coll.Posts, _ = app.db.GetLangPosts(app.cfg, c, lang, page, cr.isCollOwner)
if err != nil {
return ErrCollectionPageNotFound
}
// Serve collection
displayPage := struct {
CollectionPage
Tag string
}{
CollectionPage: CollectionPage{
DisplayCollection: coll,
StaticPage: pageForReq(app, r),
IsCustomDomain: cr.isCustomDomain,
},
Tag: lang,
}
var owner *User
if u != nil {
displayPage.Username = u.Username
displayPage.IsOwner = u.ID == coll.OwnerID
if displayPage.IsOwner {
// Add in needed information for users viewing their own collection
owner = u
displayPage.CanPin = true
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
displayPage.Collections = pubColls
}
}
isOwner := owner != nil
if !isOwner {
// Current user doesn't own collection; retrieve owner information
owner, err = app.db.GetUserByID(coll.OwnerID)
if err != nil {
// Log the error and just continue
log.Error("Error getting user for collection: %v", err)
}
if owner.IsSilenced() {
return ErrCollectionNotFound
}
}
displayPage.Silenced = owner != nil && owner.IsSilenced()
displayPage.Owner = owner
coll.Owner = displayPage.Owner
// Add more data
// TODO: fix this mess of collections inside collections
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
collTmpl := "collection"
if app.cfg.App.Chorus {
collTmpl = "chorus-collection"
}
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
if err != nil {
log.Error("Unable to render collection lang page: %v", err)
}
return nil
}
func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
func handleCollectionPostRedirect(app *app, w http.ResponseWriter, r *http.Request) error {
vars := mux.Vars(r)
slug := vars["slug"]
@ -1241,16 +885,17 @@ func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Reque
return impart.HTTPError{http.StatusFound, loc}
}
func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r)
func existingCollection(app *app, w http.ResponseWriter, r *http.Request) error {
reqJSON := IsJSON(r.Header.Get("Content-Type"))
vars := mux.Vars(r)
collAlias := vars["alias"]
isWeb := r.FormValue("web") == "1"
u := &User{}
var u *User
if reqJSON && !isWeb {
// Ensure an access token was given
accessToken := r.Header.Get("Authorization")
u = &User{}
u.ID = app.db.GetUserID(accessToken)
if u.ID == -1 {
return ErrBadAccessToken
@ -1262,16 +907,6 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
}
}
silenced, err := app.db.IsUserSilenced(u.ID)
if err != nil {
log.Error("existing collection: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrUserSilenced
}
if r.Method == "DELETE" {
err := app.db.DeleteCollection(collAlias, u.ID)
if err != nil {
@ -1284,6 +919,7 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
}
c := SubmittedCollection{OwnerID: uint64(u.ID)}
var err error
if reqJSON {
// Decode JSON request
@ -1307,7 +943,7 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
}
}
err = app.db.UpdateCollection(app, &c, collAlias)
err = app.db.UpdateCollection(&c, collAlias)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
if reqJSON {
@ -1344,7 +980,7 @@ func collectionAliasFromReq(r *http.Request) string {
return alias
}
func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error {
func handleWebCollectionUnlock(app *app, w http.ResponseWriter, r *http.Request) error {
var readReq struct {
Alias string `schema:"alias" json:"alias"`
Pass string `schema:"password" json:"password"`
@ -1411,7 +1047,7 @@ func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request)
return impart.HTTPError{http.StatusFound, next}
}
func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
func isAuthorizedForCollection(app *app, alias string, r *http.Request) bool {
authd := false
session, err := app.sessionStore.Get(r, blogPassCookieName)
if err == nil {
@ -1419,43 +1055,3 @@ func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
}
return authd
}
func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error {
session, err := app.sessionStore.Get(r, blogPassCookieName)
if err != nil {
return err
}
// Remove this from map of blogs logged into
delete(session.Values, alias)
// If not auth'd with any blog, delete entire cookie
if len(session.Values) == 0 {
session.Options.MaxAge = -1
}
return session.Save(r, w)
}
func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error {
alias := collectionAliasFromReq(r)
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 {
return err
}
if !c.IsProtected() {
// Invalid to log out of this collection
return ErrCollectionPageNotFound
}
err = logOutCollection(app, c.Alias, w, r)
if err != nil {
addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil)
}
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
}

26
config.ini.example Normal file
View file

@ -0,0 +1,26 @@
[server]
hidden_host =
port = 8080
[database]
type = mysql
username = root
password = changeme
database = writefreely
host = db
port = 3306
[app]
site_name = WriteFreely Example Blog!
host = http://localhost:8080
theme = write
disable_js = false
webfonts = true
single_user = true
open_registration = false
min_username_len = 3
max_blogs = 1
federation = true
public_stats = true
private = false

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
* Copyright © 2018-2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -12,12 +12,7 @@
package config
import (
"net/url"
"strings"
"github.com/go-ini/ini"
"github.com/writeas/web-core/log"
"golang.org/x/net/idna"
"gopkg.in/ini.v1"
)
const (
@ -39,17 +34,12 @@ type (
TLSCertPath string `ini:"tls_cert_path"`
TLSKeyPath string `ini:"tls_key_path"`
Autocert bool `ini:"autocert"`
TemplatesParentDir string `ini:"templates_parent_dir"`
StaticParentDir string `ini:"static_parent_dir"`
PagesParentDir string `ini:"pages_parent_dir"`
KeysParentDir string `ini:"keys_parent_dir"`
HashSeed string `ini:"hash_seed"`
GopherPort int `ini:"gopher_port"`
Dev bool `ini:"-"`
}
@ -62,61 +52,6 @@ type (
Database string `ini:"database"`
Host string `ini:"host"`
Port int `ini:"port"`
TLS bool `ini:"tls"`
}
WriteAsOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
AuthLocation string `ini:"auth_location"`
TokenLocation string `ini:"token_location"`
InspectLocation string `ini:"inspect_location"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GitlabOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GiteaOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
SlackOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
TeamID string `ini:"team_id"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
}
GenericOauthCfg struct {
ClientID string `ini:"client_id"`
ClientSecret string `ini:"client_secret"`
Host string `ini:"host"`
DisplayName string `ini:"display_name"`
CallbackProxy string `ini:"callback_proxy"`
CallbackProxyAPI string `ini:"callback_proxy_api"`
TokenEndpoint string `ini:"token_endpoint"`
InspectEndpoint string `ini:"inspect_endpoint"`
AuthEndpoint string `ini:"auth_endpoint"`
Scope string `ini:"scope"`
AllowDisconnect bool `ini:"allow_disconnect"`
MapUserID string `ini:"map_user_id"`
MapUsername string `ini:"map_username"`
MapDisplayName string `ini:"map_display_name"`
MapEmail string `ini:"map_email"`
}
// AppCfg holds values that affect how the application functions
@ -127,74 +62,30 @@ type (
// Site appearance
Theme string `ini:"theme"`
Editor string `ini:"editor"`
JSDisabled bool `ini:"disable_js"`
WebFonts bool `ini:"webfonts"`
Landing string `ini:"landing"`
SimpleNav bool `ini:"simple_nav"`
WFModesty bool `ini:"wf_modesty"`
// Site functionality
Chorus bool `ini:"chorus"`
Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
DisableDrafts bool `ini:"disable_drafts"`
// Users
SingleUser bool `ini:"single_user"`
OpenRegistration bool `ini:"open_registration"`
OpenDeletion bool `ini:"open_deletion"`
MinUsernameLen int `ini:"min_username_len"`
MaxBlogs int `ini:"max_blogs"`
// Options for public instances
// Federation
Federation bool `ini:"federation"`
PublicStats bool `ini:"public_stats"`
Monetization bool `ini:"monetization"`
NotesOnly bool `ini:"notes_only"`
// Access
Private bool `ini:"private"`
Federation bool `ini:"federation"`
PublicStats bool `ini:"public_stats"`
Private bool `ini:"private"`
// Additional functions
LocalTimeline bool `ini:"local_timeline"`
UserInvites string `ini:"user_invites"`
// Defaults
DefaultVisibility string `ini:"default_visibility"`
// Check for Updates
UpdateChecks bool `ini:"update_checks"`
// Disable password authentication if use only Oauth
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"`
GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"`
GenericOauth GenericOauthCfg `ini:"oauth.generic"`
Server ServerCfg `ini:"server"`
Database DatabaseCfg `ini:"database"`
App AppCfg `ini:"app"`
}
)
@ -243,28 +134,6 @@ func (cfg *Config) IsSecureStandalone() bool {
return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != ""
}
func (ac *AppCfg) LandingPath() string {
if !strings.HasPrefix(ac.Landing, "/") {
return "/" + ac.Landing
}
return ac.Landing
}
func (lc EmailCfg) Enabled() bool {
return (lc.Domain != "" && lc.MailgunPrivate != "") ||
lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0
}
func (ac AppCfg) SignupPath() string {
if !ac.OpenRegistration {
return ""
}
if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") {
return "/signup"
}
return "/"
}
// Load reads the given configuration file, then parses and returns it as a Config.
func Load(fname string) (*Config, error) {
if fname == "" {
@ -281,22 +150,6 @@ func Load(fname string) (*Config, error) {
if err != nil {
return nil, err
}
// Do any transformations
u, err := url.Parse(uc.App.Host)
if err != nil {
return nil, err
}
d, err := idna.ToASCII(u.Hostname())
if err != nil {
log.Error("idna.ToASCII for %s: %s", u.Hostname(), err)
return nil, err
}
uc.App.Host = u.Scheme + "://" + d
if u.Port() != "" {
uc.App.Host += ":" + u.Port()
}
return uc, nil
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018, 2020-2021 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -11,34 +11,12 @@
package config
import (
"github.com/writeas/web-core/log"
"golang.org/x/net/idna"
"net/http"
"net/url"
"strings"
"time"
)
// FriendlyHost returns the app's Host sans any schema
func (ac AppCfg) FriendlyHost() string {
rawHost := ac.Host[strings.Index(ac.Host, "://")+len("://"):]
u, err := url.Parse(ac.Host)
if err != nil {
log.Error("url.Parse failed on %s: %s", ac.Host, err)
return rawHost
}
d, err := idna.ToUnicode(u.Hostname())
if err != nil {
log.Error("idna.ToUnicode failed on %s: %s", ac.Host, err)
return rawHost
}
res := d
if u.Port() != "" {
res += ":" + u.Port()
}
return res
return ac.Host[strings.Index(ac.Host, "://")+len("://"):]
}
func (ac AppCfg) CanCreateBlogs(currentlyUsed uint64) bool {
@ -47,16 +25,3 @@ func (ac AppCfg) CanCreateBlogs(currentlyUsed uint64) bool {
}
return int(currentlyUsed) < ac.MaxBlogs
}
// OrDefaultString returns input or a default value if input is empty.
func OrDefaultString(input, defaultValue string) string {
if len(input) == 0 {
return defaultValue
}
return input
}
// DefaultHTTPClient returns a sane default HTTP client.
func DefaultHTTPClient() *http.Client {
return &http.Client{Timeout: 10 * time.Second}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -12,14 +12,11 @@ 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"
)
type SetupData struct {
@ -27,7 +24,7 @@ type SetupData struct {
Config *Config
}
func Configure(fname string, configSections string) (*SetupData, error) {
func Configure(fname string) (*SetupData, error) {
data := &SetupData{}
var err error
if fname == "" {
@ -55,341 +52,310 @@ func Configure(fname string, configSections string) (*SetupData, error) {
fmt.Println(wordwrap.WrapString(" This quick configuration process will "+action+" the application's config file, "+fname+".\n\n It validates your input along the way, so you can be sure any future errors aren't caused by a bad configuration. If you'd rather configure your server manually, instead run: writefreely --create-config and edit that file.", 75))
fmt.Println()
title(" Server setup ")
fmt.Println()
tmpls := &promptui.PromptTemplates{
Success: "{{ . | bold | faint }}: ",
}
selTmpls := &promptui.SelectTemplates{
Selected: `{{.Label}} {{ . | faint }}`,
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`),
}
var selPrompt promptui.Select
// Environment selection
selPrompt := promptui.Select{
Templates: selTmpls,
Label: "Environment",
Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"},
}
_, envType, err := selPrompt.Run()
if err != nil {
return data, err
}
isDevEnv := envType == "Development"
isStandalone := envType == "Production, standalone"
data.Config.Server.Dev = isDevEnv
var prompt promptui.Prompt
if strings.Contains(configSections, "server") {
title(" Server setup ")
fmt.Println()
// Environment selection
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Environment",
Items: []string{"Development", "Production, standalone", "Production, behind reverse proxy"},
if isDevEnv || !isStandalone {
// Running in dev environment or behind reverse proxy; ask for port
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Local port",
Validate: validatePort,
Default: fmt.Sprintf("%d", data.Config.Server.Port),
}
_, envType, err := selPrompt.Run()
port, err := prompt.Run()
if err != nil {
return data, err
}
isDevEnv := envType == "Development"
isStandalone := envType == "Production, standalone"
_, isDocker := os.LookupEnv("WRITEFREELY_DOCKER")
data.Config.Server.Dev = isDevEnv
if isDevEnv || !isStandalone {
// Running in dev environment or behind reverse proxy; ask for port
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Local port",
Validate: validatePort,
Default: fmt.Sprintf("%d", data.Config.Server.Port),
}
port, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number
}
if isStandalone {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Web server mode",
Items: []string{"Insecure (port 80)", "Secure (port 443), manual certificate", "Secure (port 443), auto certificate"},
}
sel, _, err := selPrompt.Run()
if err != nil {
return data, err
}
if sel == 0 {
data.Config.Server.Autocert = false
data.Config.Server.Port = 80
data.Config.Server.TLSCertPath = ""
data.Config.Server.TLSKeyPath = ""
} else if sel == 1 || sel == 2 {
data.Config.Server.Port = 443
data.Config.Server.Autocert = sel == 2
if sel == 1 {
// Manual certificate configuration
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Certificate path",
Validate: validateNonEmpty,
Default: data.Config.Server.TLSCertPath,
}
data.Config.Server.TLSCertPath, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Key path",
Validate: validateNonEmpty,
Default: data.Config.Server.TLSKeyPath,
}
data.Config.Server.TLSKeyPath, err = prompt.Run()
if err != nil {
return data, err
}
} else {
// Automatic certificate
data.Config.Server.TLSCertPath = "certs"
data.Config.Server.TLSKeyPath = "certs"
}
}
} else {
data.Config.Server.TLSCertPath = ""
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()
data.Config.Server.Port, _ = strconv.Atoi(port) // Ignore error, as we've already validated number
}
if strings.Contains(configSections, "db") {
title(" Database setup ")
fmt.Println()
if isStandalone {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Database driver",
Items: []string{"MySQL", "SQLite"},
Label: "Web server mode",
Items: []string{"Insecure (port 80)", "Secure (port 443)"},
}
sel, _, err := selPrompt.Run()
if err != nil {
return data, err
}
if sel == 0 {
// Configure for MySQL
data.Config.UseMySQL(isNewCfg)
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Username",
Validate: validateNonEmpty,
Default: data.Config.Database.User,
}
data.Config.Database.User, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Password",
Validate: validateNonEmpty,
Default: data.Config.Database.Password,
Mask: '*',
}
data.Config.Database.Password, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Database name",
Validate: validateNonEmpty,
Default: data.Config.Database.Database,
}
data.Config.Database.Database, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Host",
Validate: validateNonEmpty,
Default: data.Config.Database.Host,
}
data.Config.Database.Host, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Port",
Validate: validatePort,
Default: fmt.Sprintf("%d", data.Config.Database.Port),
}
dbPort, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number
data.Config.Server.Port = 80
data.Config.Server.TLSCertPath = ""
data.Config.Server.TLSKeyPath = ""
} else if sel == 1 {
// Configure for SQLite
data.Config.UseSQLite(isNewCfg)
data.Config.Server.Port = 443
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Filename",
Label: "Certificate path",
Validate: validateNonEmpty,
Default: data.Config.Database.FileName,
Default: data.Config.Server.TLSCertPath,
}
data.Config.Database.FileName, err = prompt.Run()
data.Config.Server.TLSCertPath, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Key path",
Validate: validateNonEmpty,
Default: data.Config.Server.TLSKeyPath,
}
data.Config.Server.TLSKeyPath, err = prompt.Run()
if err != nil {
return data, err
}
}
fmt.Println()
} else {
data.Config.Server.TLSCertPath = ""
data.Config.Server.TLSKeyPath = ""
}
if strings.Contains(configSections, "app") {
title(" App setup ")
fmt.Println()
fmt.Println()
title(" Database setup ")
fmt.Println()
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Site type",
Items: []string{"Single user blog", "Multi-user instance"},
}
_, usersType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.SingleUser = usersType == "Single user blog"
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Database driver",
Items: []string{"MySQL", "SQLite"},
}
sel, _, err := selPrompt.Run()
if err != nil {
return data, err
}
if data.Config.App.SingleUser {
data.User = &UserCreation{}
if sel == 0 {
// Configure for MySQL
data.Config.UseMySQL(isNewCfg)
// prompt for username
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Admin username",
Validate: validateNonEmpty,
}
data.User.Username, err = prompt.Run()
if err != nil {
return data, err
}
// prompt for password
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Admin password",
Validate: validateNonEmpty,
}
newUserPass, err := prompt.Run()
if err != nil {
return data, err
}
data.User.HashedPass, err = auth.HashPass([]byte(newUserPass))
if err != nil {
return data, err
}
}
siteNameLabel := "Instance name"
if data.Config.App.SingleUser {
siteNameLabel = "Blog name"
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: siteNameLabel,
Label: "Username",
Validate: validateNonEmpty,
Default: data.Config.App.SiteName,
Default: data.Config.Database.User,
}
data.Config.App.SiteName, err = prompt.Run()
data.Config.Database.User, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Public URL",
Validate: validateDomain,
Default: data.Config.App.Host,
Label: "Password",
Validate: validateNonEmpty,
Default: data.Config.Database.Password,
Mask: '*',
}
data.Config.App.Host, err = prompt.Run()
data.Config.Database.Password, err = prompt.Run()
if err != nil {
return data, err
}
if !data.Config.App.SingleUser {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Registration",
Items: []string{"Open", "Closed"},
}
_, regType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.OpenRegistration = regType == "Open"
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Max blogs per user",
Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs),
}
maxBlogs, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Database name",
Validate: validateNonEmpty,
Default: data.Config.Database.Database,
}
data.Config.Database.Database, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Host",
Validate: validateNonEmpty,
Default: data.Config.Database.Host,
}
data.Config.Database.Host, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Port",
Validate: validatePort,
Default: fmt.Sprintf("%d", data.Config.Database.Port),
}
dbPort, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.Database.Port, _ = strconv.Atoi(dbPort) // Ignore error, as we've already validated number
} else if sel == 1 {
// Configure for SQLite
data.Config.UseSQLite(isNewCfg)
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Filename",
Validate: validateNonEmpty,
Default: data.Config.Database.FileName,
}
data.Config.Database.FileName, err = prompt.Run()
if err != nil {
return data, err
}
}
fmt.Println()
title(" App setup ")
fmt.Println()
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Site type",
Items: []string{"Single user blog", "Multi-user instance"},
}
_, usersType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.SingleUser = usersType == "Single user blog"
if data.Config.App.SingleUser {
data.User = &UserCreation{}
// prompt for username
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Admin username",
Validate: validateNonEmpty,
}
data.User.Username, err = prompt.Run()
if err != nil {
return data, err
}
// prompt for password
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Admin password",
Validate: validateNonEmpty,
}
newUserPass, err := prompt.Run()
if err != nil {
return data, err
}
data.User.HashedPass, err = auth.HashPass([]byte(newUserPass))
if err != nil {
return data, err
}
}
siteNameLabel := "Instance name"
if data.Config.App.SingleUser {
siteNameLabel = "Blog name"
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: siteNameLabel,
Validate: validateNonEmpty,
Default: data.Config.App.SiteName,
}
data.Config.App.SiteName, err = prompt.Run()
if err != nil {
return data, err
}
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Public URL",
Validate: validateDomain,
Default: data.Config.App.Host,
}
data.Config.App.Host, err = prompt.Run()
if err != nil {
return data, err
}
if !data.Config.App.SingleUser {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Registration",
Items: []string{"Open", "Closed"},
}
_, regType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.OpenRegistration = regType == "Open"
prompt = promptui.Prompt{
Templates: tmpls,
Label: "Max blogs per user",
Default: fmt.Sprintf("%d", data.Config.App.MaxBlogs),
}
maxBlogs, err := prompt.Run()
if err != nil {
return data, err
}
data.Config.App.MaxBlogs, _ = strconv.Atoi(maxBlogs) // Ignore error, as we've already validated number
}
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Federation",
Items: []string{"Enabled", "Disabled"},
}
_, fedType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.Federation = fedType == "Enabled"
if data.Config.App.Federation {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Federation usage stats",
Items: []string{"Public", "Private"},
}
_, fedStatsType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.PublicStats = fedStatsType == "Public"
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Federation",
Items: []string{"Enabled", "Disabled"},
Label: "Instance metadata privacy",
Items: []string{"Public", "Private"},
}
_, fedType, err := selPrompt.Run()
_, fedStatsType, err = selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.Federation = fedType == "Enabled"
if data.Config.App.Federation {
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Usage stats (active users, posts)",
Items: []string{"Public", "Private"},
}
_, fedStatsType, err := selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.PublicStats = fedStatsType == "Public"
selPrompt = promptui.Select{
Templates: selTmpls,
Label: "Instance metadata privacy",
Items: []string{"Public", "Private"},
}
_, fedStatsType, err = selPrompt.Run()
if err != nil {
return data, err
}
data.Config.App.Private = fedStatsType == "Private"
}
data.Config.App.Private = fedStatsType == "Private"
}
return data, Save(data.Config, fname)

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*

View file

@ -1,29 +0,0 @@
//go:build wflib
// +build wflib
/*
* Copyright © 2019-2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
// This file contains dummy database funcs for when writefreely is used as a
// library.
package writefreely
func (db *datastore) isDuplicateKeyErr(err error) bool {
return false
}
func (db *datastore) isIgnorableError(err error) bool {
return false
}
func (db *datastore) isHighLoadError(err error) bool {
return false
}

View file

@ -1,8 +1,7 @@
//go:build !sqlite && !wflib
// +build !sqlite,!wflib
// +build !sqlite
/*
* Copyright © 2019-2020 Musing Studio LLC.
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -29,25 +28,3 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
return false
}
func (db *datastore) isIgnorableError(err error) bool {
if db.driverName == driverMySQL {
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
return mysqlErr.Number == mySQLErrCollationMix
}
} else {
log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName)
}
return false
}
func (db *datastore) isHighLoadError(err error) bool {
if db.driverName == driverMySQL {
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
}
}
return false
}

View file

@ -1,8 +1,7 @@
//go:build sqlite && !wflib
// +build sqlite,!wflib
// +build sqlite
/*
* Copyright © 2019-2020 Musing Studio LLC.
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -49,25 +48,3 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
return false
}
func (db *datastore) isIgnorableError(err error) bool {
if db.driverName == driverMySQL {
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
return mysqlErr.Number == mySQLErrCollationMix
}
} else {
log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName)
}
return false
}
func (db *datastore) isHighLoadError(err error) bool {
if db.driverName == driverMySQL {
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
}
}
return false
}

File diff suppressed because it is too large Load diff

View file

@ -1,49 +0,0 @@
/*
* 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
}

View file

@ -1,50 +0,0 @@
package writefreely
import (
"context"
"database/sql"
"github.com/stretchr/testify/assert"
"testing"
)
func TestOAuthDatastore(t *testing.T) {
if !runMySQLTests() {
t.Skip("skipping mysql tests")
}
withTestDB(t, func(db *sql.DB) {
ctx := context.Background()
ds := &datastore{
DB: db,
driverName: "",
}
state, err := ds.GenerateOAuthState(ctx, "test", "development", 0, "")
assert.NoError(t, err)
assert.Len(t, state, 24)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state)
_, _, _, _, err = ds.ValidateOAuthState(ctx, state)
assert.NoError(t, err)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)
var localUserID int64 = 99
var remoteUserID = "100"
err = ds.RecordRemoteUserID(ctx, localUserID, remoteUserID, "test", "test", "access_token_a")
assert.NoError(t, err)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users` WHERE `user_id` = ? AND `remote_user_id` = ? AND access_token = 'access_token_a'", localUserID, remoteUserID)
err = ds.RecordRemoteUserID(ctx, localUserID, remoteUserID, "test", "test", "access_token_b")
assert.NoError(t, err)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users` WHERE `user_id` = ? AND `remote_user_id` = ? AND access_token = 'access_token_b'", localUserID, remoteUserID)
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_users`")
foundUserID, err := ds.GetIDForRemoteUser(ctx, remoteUserID, "test", "test")
assert.NoError(t, err)
assert.Equal(t, localUserID, foundUserID)
})
}

View file

@ -1,52 +0,0 @@
package db
import (
"fmt"
"strings"
)
type AlterTableSqlBuilder struct {
Dialect DialectType
Name string
Changes []string
}
func (b *AlterTableSqlBuilder) AddColumn(col *Column) *AlterTableSqlBuilder {
if colVal, err := col.String(); err == nil {
b.Changes = append(b.Changes, fmt.Sprintf("ADD COLUMN %s", colVal))
}
return b
}
func (b *AlterTableSqlBuilder) ChangeColumn(name string, col *Column) *AlterTableSqlBuilder {
if colVal, err := col.String(); err == nil {
b.Changes = append(b.Changes, fmt.Sprintf("CHANGE COLUMN %s %s", name, colVal))
}
return b
}
func (b *AlterTableSqlBuilder) AddUniqueConstraint(name string, columns ...string) *AlterTableSqlBuilder {
b.Changes = append(b.Changes, fmt.Sprintf("ADD CONSTRAINT %s UNIQUE (%s)", name, strings.Join(columns, ", ")))
return b
}
func (b *AlterTableSqlBuilder) ToSQL() (string, error) {
var str strings.Builder
str.WriteString("ALTER TABLE ")
str.WriteString(b.Name)
str.WriteString(" ")
if len(b.Changes) == 0 {
return "", fmt.Errorf("no changes provide for table: %s", b.Name)
}
changeCount := len(b.Changes)
for i, thing := range b.Changes {
str.WriteString(thing)
if i < changeCount-1 {
str.WriteString(", ")
}
}
return str.String(), nil
}

View file

@ -1,56 +0,0 @@
package db
import "testing"
func TestAlterTableSqlBuilder_ToSQL(t *testing.T) {
type fields struct {
Dialect DialectType
Name string
Changes []string
}
tests := []struct {
name string
builder *AlterTableSqlBuilder
want string
wantErr bool
}{
{
name: "MySQL add int",
builder: DialectMySQL.
AlterTable("the_table").
AddColumn(DialectMySQL.Column("the_col", ColumnTypeInteger, UnsetSize)),
want: "ALTER TABLE the_table ADD COLUMN the_col INT NOT NULL",
wantErr: false,
},
{
name: "MySQL add string",
builder: DialectMySQL.
AlterTable("the_table").
AddColumn(DialectMySQL.Column("the_col", ColumnTypeVarChar, OptionalInt{true, 128})),
want: "ALTER TABLE the_table ADD COLUMN the_col VARCHAR(128) NOT NULL",
wantErr: false,
},
{
name: "MySQL add int and string",
builder: DialectMySQL.
AlterTable("the_table").
AddColumn(DialectMySQL.Column("first_col", ColumnTypeInteger, UnsetSize)).
AddColumn(DialectMySQL.Column("second_col", ColumnTypeVarChar, OptionalInt{true, 128})),
want: "ALTER TABLE the_table ADD COLUMN first_col INT NOT NULL, ADD COLUMN second_col VARCHAR(128) NOT NULL",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.builder.ToSQL()
if (err != nil) != tt.wantErr {
t.Errorf("ToSQL() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ToSQL() got = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -1,263 +0,0 @@
/*
* Copyright © 2019-2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package db
import (
"fmt"
"strings"
)
type ColumnType int
type OptionalInt struct {
Set bool
Value int
}
type OptionalString struct {
Set bool
Value string
}
type SQLBuilder interface {
ToSQL() (string, error)
}
type Column struct {
Dialect DialectType
Name string
Nullable bool
Default OptionalString
Type ColumnType
Size OptionalInt
PrimaryKey bool
}
type CreateTableSqlBuilder struct {
Dialect DialectType
Name string
IfNotExists bool
ColumnOrder []string
Columns map[string]*Column
Constraints []string
}
const (
ColumnTypeBool ColumnType = iota
ColumnTypeSmallInt ColumnType = iota
ColumnTypeInteger ColumnType = iota
ColumnTypeChar ColumnType = iota
ColumnTypeVarChar ColumnType = iota
ColumnTypeText ColumnType = iota
ColumnTypeDateTime ColumnType = iota
)
var _ SQLBuilder = &CreateTableSqlBuilder{}
var UnsetSize OptionalInt = OptionalInt{Set: false, Value: 0}
var UnsetDefault OptionalString = OptionalString{Set: false, Value: ""}
func (d ColumnType) Format(dialect DialectType, size OptionalInt) (string, error) {
if dialect != DialectMySQL && dialect != DialectSQLite {
return "", fmt.Errorf("unsupported column type %d for dialect %d and size %v", d, dialect, size)
}
switch d {
case ColumnTypeSmallInt:
{
if dialect == DialectSQLite {
return "INTEGER", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "SMALLINT" + mod, nil
}
case ColumnTypeInteger:
{
if dialect == DialectSQLite {
return "INTEGER", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "INT" + mod, nil
}
case ColumnTypeChar:
{
if dialect == DialectSQLite {
return "TEXT", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "CHAR" + mod, nil
}
case ColumnTypeVarChar:
{
if dialect == DialectSQLite {
return "TEXT", nil
}
mod := ""
if size.Set {
mod = fmt.Sprintf("(%d)", size.Value)
}
return "VARCHAR" + mod, nil
}
case ColumnTypeBool:
{
if dialect == DialectSQLite {
return "INTEGER", nil
}
return "TINYINT(1)", nil
}
case ColumnTypeDateTime:
return "DATETIME", nil
case ColumnTypeText:
return "TEXT", nil
}
return "", fmt.Errorf("unsupported column type %d for dialect %d and size %v", d, dialect, size)
}
func (c *Column) SetName(name string) *Column {
c.Name = name
return c
}
func (c *Column) SetNullable(nullable bool) *Column {
c.Nullable = nullable
return c
}
func (c *Column) SetPrimaryKey(pk bool) *Column {
c.PrimaryKey = pk
return c
}
func (c *Column) SetDefault(value string) *Column {
c.Default = OptionalString{Set: true, Value: value}
return c
}
func (c *Column) SetDefaultCurrentTimestamp() *Column {
def := "NOW()"
if c.Dialect == DialectSQLite {
def = "CURRENT_TIMESTAMP"
}
c.Default = OptionalString{Set: true, Value: def}
return c
}
func (c *Column) SetType(t ColumnType) *Column {
c.Type = t
return c
}
func (c *Column) SetSize(size int) *Column {
c.Size = OptionalInt{Set: true, Value: size}
return c
}
func (c *Column) String() (string, error) {
var str strings.Builder
str.WriteString(c.Name)
str.WriteString(" ")
typeStr, err := c.Type.Format(c.Dialect, c.Size)
if err != nil {
return "", err
}
str.WriteString(typeStr)
if !c.Nullable {
str.WriteString(" NOT NULL")
}
if c.Default.Set {
str.WriteString(" DEFAULT ")
val := c.Default.Value
if val == "" {
val = "''"
}
str.WriteString(val)
}
if c.PrimaryKey {
str.WriteString(" PRIMARY KEY")
}
return str.String(), nil
}
func (b *CreateTableSqlBuilder) Column(column *Column) *CreateTableSqlBuilder {
if b.Columns == nil {
b.Columns = make(map[string]*Column)
}
b.Columns[column.Name] = column
b.ColumnOrder = append(b.ColumnOrder, column.Name)
return b
}
func (b *CreateTableSqlBuilder) UniqueConstraint(columns ...string) *CreateTableSqlBuilder {
for _, column := range columns {
if _, ok := b.Columns[column]; !ok {
// This fails silently.
return b
}
}
b.Constraints = append(b.Constraints, fmt.Sprintf("UNIQUE(%s)", strings.Join(columns, ",")))
return b
}
func (b *CreateTableSqlBuilder) SetIfNotExists(ine bool) *CreateTableSqlBuilder {
b.IfNotExists = ine
return b
}
func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
var str strings.Builder
str.WriteString("CREATE TABLE ")
if b.IfNotExists {
str.WriteString("IF NOT EXISTS ")
}
str.WriteString(b.Name)
var things []string
for _, columnName := range b.ColumnOrder {
column, ok := b.Columns[columnName]
if !ok {
return "", fmt.Errorf("column not found: %s", columnName)
}
columnStr, err := column.String()
if err != nil {
return "", err
}
things = append(things, columnStr)
}
things = append(things, b.Constraints...)
if thingLen := len(things); thingLen > 0 {
str.WriteString(" ( ")
for i, thing := range things {
str.WriteString(thing)
if i < thingLen-1 {
str.WriteString(", ")
}
}
str.WriteString(" )")
}
return str.String(), nil
}

View file

@ -1,146 +0,0 @@
package db
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestDialect_Column(t *testing.T) {
c1 := DialectSQLite.Column("foo", ColumnTypeBool, UnsetSize)
assert.Equal(t, DialectSQLite, c1.Dialect)
c2 := DialectMySQL.Column("foo", ColumnTypeBool, UnsetSize)
assert.Equal(t, DialectMySQL, c2.Dialect)
}
func TestColumnType_Format(t *testing.T) {
type args struct {
dialect DialectType
size OptionalInt
}
tests := []struct {
name string
d ColumnType
args args
want string
wantErr bool
}{
{"Sqlite bool", ColumnTypeBool, args{dialect: DialectSQLite}, "INTEGER", false},
{"Sqlite small int", ColumnTypeSmallInt, args{dialect: DialectSQLite}, "INTEGER", false},
{"Sqlite int", ColumnTypeInteger, args{dialect: DialectSQLite}, "INTEGER", false},
{"Sqlite char", ColumnTypeChar, args{dialect: DialectSQLite}, "TEXT", false},
{"Sqlite varchar", ColumnTypeVarChar, args{dialect: DialectSQLite}, "TEXT", false},
{"Sqlite text", ColumnTypeText, args{dialect: DialectSQLite}, "TEXT", false},
{"Sqlite datetime", ColumnTypeDateTime, args{dialect: DialectSQLite}, "DATETIME", false},
{"MySQL bool", ColumnTypeBool, args{dialect: DialectMySQL}, "TINYINT(1)", false},
{"MySQL small int", ColumnTypeSmallInt, args{dialect: DialectMySQL}, "SMALLINT", false},
{"MySQL small int with param", ColumnTypeSmallInt, args{dialect: DialectMySQL, size: OptionalInt{true, 3}}, "SMALLINT(3)", false},
{"MySQL int", ColumnTypeInteger, args{dialect: DialectMySQL}, "INT", false},
{"MySQL int with param", ColumnTypeInteger, args{dialect: DialectMySQL, size: OptionalInt{true, 11}}, "INT(11)", false},
{"MySQL char", ColumnTypeChar, args{dialect: DialectMySQL}, "CHAR", false},
{"MySQL char with param", ColumnTypeChar, args{dialect: DialectMySQL, size: OptionalInt{true, 4}}, "CHAR(4)", false},
{"MySQL varchar", ColumnTypeVarChar, args{dialect: DialectMySQL}, "VARCHAR", false},
{"MySQL varchar with param", ColumnTypeVarChar, args{dialect: DialectMySQL, size: OptionalInt{true, 25}}, "VARCHAR(25)", false},
{"MySQL text", ColumnTypeText, args{dialect: DialectMySQL}, "TEXT", false},
{"MySQL datetime", ColumnTypeDateTime, args{dialect: DialectMySQL}, "DATETIME", false},
{"invalid column type", 10000, args{dialect: DialectMySQL}, "", true},
{"invalid dialect", ColumnTypeBool, args{dialect: 10000}, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.d.Format(tt.args.dialect, tt.args.size)
if (err != nil) != tt.wantErr {
t.Errorf("Format() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Format() got = %v, want %v", got, tt.want)
}
})
}
}
func TestColumn_Build(t *testing.T) {
type fields struct {
Dialect DialectType
Name string
Nullable bool
Default OptionalString
Type ColumnType
Size OptionalInt
PrimaryKey bool
}
tests := []struct {
name string
fields fields
want string
wantErr bool
}{
{"Sqlite bool", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo INTEGER NOT NULL", false},
{"Sqlite bool nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo INTEGER", false},
{"Sqlite small int", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeSmallInt, UnsetSize, true}, "foo INTEGER NOT NULL PRIMARY KEY", false},
{"Sqlite small int nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeSmallInt, UnsetSize, false}, "foo INTEGER", false},
{"Sqlite int", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INTEGER NOT NULL", false},
{"Sqlite int nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INTEGER", false},
{"Sqlite char", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"Sqlite char nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo TEXT", false},
{"Sqlite varchar", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"Sqlite varchar nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo TEXT", false},
{"Sqlite text", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"Sqlite text nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT", false},
{"Sqlite datetime", fields{DialectSQLite, "foo", false, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME NOT NULL", false},
{"Sqlite datetime nullable", fields{DialectSQLite, "foo", true, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME", false},
{"MySQL bool", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo TINYINT(1) NOT NULL", false},
{"MySQL bool nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeBool, UnsetSize, false}, "foo TINYINT(1)", false},
{"MySQL small int", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeSmallInt, UnsetSize, true}, "foo SMALLINT NOT NULL PRIMARY KEY", false},
{"MySQL small int nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeSmallInt, UnsetSize, false}, "foo SMALLINT", false},
{"MySQL int", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INT NOT NULL", false},
{"MySQL int nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeInteger, UnsetSize, false}, "foo INT", false},
{"MySQL char", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo CHAR NOT NULL", false},
{"MySQL char nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeChar, UnsetSize, false}, "foo CHAR", false},
{"MySQL varchar", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo VARCHAR NOT NULL", false},
{"MySQL varchar nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeVarChar, UnsetSize, false}, "foo VARCHAR", false},
{"MySQL text", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT NOT NULL", false},
{"MySQL text nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeText, UnsetSize, false}, "foo TEXT", false},
{"MySQL datetime", fields{DialectMySQL, "foo", false, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME NOT NULL", false},
{"MySQL datetime nullable", fields{DialectMySQL, "foo", true, UnsetDefault, ColumnTypeDateTime, UnsetSize, false}, "foo DATETIME", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Column{
Dialect: tt.fields.Dialect,
Name: tt.fields.Name,
Nullable: tt.fields.Nullable,
Default: tt.fields.Default,
Type: tt.fields.Type,
Size: tt.fields.Size,
PrimaryKey: tt.fields.PrimaryKey,
}
if got, err := c.String(); got != tt.want {
if (err != nil) != tt.wantErr {
t.Errorf("String() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("String() got = %v, want %v", got, tt.want)
}
}
})
}
}
func TestCreateTableSqlBuilder_ToSQL(t *testing.T) {
sql, err := DialectMySQL.
Table("foo").
SetIfNotExists(true).
Column(DialectMySQL.Column("bar", ColumnTypeInteger, UnsetSize).SetPrimaryKey(true)).
Column(DialectMySQL.Column("baz", ColumnTypeText, UnsetSize)).
Column(DialectMySQL.Column("qux", ColumnTypeDateTime, UnsetSize).SetDefault("NOW()")).
UniqueConstraint("bar").
UniqueConstraint("bar", "baz").
ToSQL()
assert.NoError(t, err)
assert.Equal(t, "CREATE TABLE IF NOT EXISTS foo ( bar INT NOT NULL PRIMARY KEY, baz TEXT NOT NULL, qux DATETIME NOT NULL DEFAULT NOW(), UNIQUE(bar), UNIQUE(bar,baz) )", sql)
}

View file

@ -1,76 +0,0 @@
package db
import "fmt"
type DialectType int
const (
DialectSQLite DialectType = iota
DialectMySQL DialectType = iota
)
func (d DialectType) Column(name string, t ColumnType, size OptionalInt) *Column {
switch d {
case DialectSQLite:
return &Column{Dialect: DialectSQLite, Name: name, Type: t, Size: size}
case DialectMySQL:
return &Column{Dialect: DialectMySQL, Name: name, Type: t, Size: size}
default:
panic(fmt.Sprintf("unexpected dialect: %d", d))
}
}
func (d DialectType) Table(name string) *CreateTableSqlBuilder {
switch d {
case DialectSQLite:
return &CreateTableSqlBuilder{Dialect: DialectSQLite, Name: name}
case DialectMySQL:
return &CreateTableSqlBuilder{Dialect: DialectMySQL, Name: name}
default:
panic(fmt.Sprintf("unexpected dialect: %d", d))
}
}
func (d DialectType) AlterTable(name string) *AlterTableSqlBuilder {
switch d {
case DialectSQLite:
return &AlterTableSqlBuilder{Dialect: DialectSQLite, Name: name}
case DialectMySQL:
return &AlterTableSqlBuilder{Dialect: DialectMySQL, Name: name}
default:
panic(fmt.Sprintf("unexpected dialect: %d", d))
}
}
func (d DialectType) CreateUniqueIndex(name, table string, columns ...string) *CreateIndexSqlBuilder {
switch d {
case DialectSQLite:
return &CreateIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table, Unique: true, Columns: columns}
case DialectMySQL:
return &CreateIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table, Unique: true, Columns: columns}
default:
panic(fmt.Sprintf("unexpected dialect: %d", d))
}
}
func (d DialectType) CreateIndex(name, table string, columns ...string) *CreateIndexSqlBuilder {
switch d {
case DialectSQLite:
return &CreateIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table, Unique: false, Columns: columns}
case DialectMySQL:
return &CreateIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table, Unique: false, Columns: columns}
default:
panic(fmt.Sprintf("unexpected dialect: %d", d))
}
}
func (d DialectType) DropIndex(name, table string) *DropIndexSqlBuilder {
switch d {
case DialectSQLite:
return &DropIndexSqlBuilder{Dialect: DialectSQLite, Name: name, Table: table}
case DialectMySQL:
return &DropIndexSqlBuilder{Dialect: DialectMySQL, Name: name, Table: table}
default:
panic(fmt.Sprintf("unexpected dialect: %d", d))
}
}

View file

@ -1,53 +0,0 @@
package db
import (
"fmt"
"strings"
)
type CreateIndexSqlBuilder struct {
Dialect DialectType
Name string
Table string
Unique bool
Columns []string
}
type DropIndexSqlBuilder struct {
Dialect DialectType
Name string
Table string
}
func (b *CreateIndexSqlBuilder) ToSQL() (string, error) {
var str strings.Builder
str.WriteString("CREATE ")
if b.Unique {
str.WriteString("UNIQUE ")
}
str.WriteString("INDEX ")
str.WriteString(b.Name)
str.WriteString(" on ")
str.WriteString(b.Table)
if len(b.Columns) == 0 {
return "", fmt.Errorf("columns provided for this index: %s", b.Name)
}
str.WriteString(" (")
columnCount := len(b.Columns)
for i, thing := range b.Columns {
str.WriteString(thing)
if i < columnCount-1 {
str.WriteString(", ")
}
}
str.WriteString(")")
return str.String(), nil
}
func (b *DropIndexSqlBuilder) ToSQL() (string, error) {
return fmt.Sprintf("DROP INDEX %s on %s", b.Name, b.Table), nil
}

View file

@ -1,9 +0,0 @@
package db
type RawSqlBuilder struct {
Query string
}
func (b *RawSqlBuilder) ToSQL() (string, error) {
return b.Query, nil
}

View file

@ -1,25 +0,0 @@
package db
import (
"context"
"database/sql"
)
// TransactionScopedWork describes code executed within a database transaction.
type TransactionScopedWork func(ctx context.Context, db *sql.Tx) error
// RunTransactionWithOptions executes a block of code within a database transaction.
func RunTransactionWithOptions(ctx context.Context, db *sql.DB, txOpts *sql.TxOptions, txWork TransactionScopedWork) error {
tx, err := db.BeginTx(ctx, txOpts)
if err != nil {
return err
}
if err = txWork(ctx, tx); err != nil {
if txErr := tx.Rollback(); txErr != nil {
return txErr
}
return err
}
return tx.Commit()
}

View file

@ -1,25 +0,0 @@
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

View file

@ -1,47 +1,32 @@
version: "3"
volumes:
web-keys:
db-data:
networks:
external_writefreely:
internal_writefreely:
internal: true
services:
writefreely-web:
container_name: "writefreely-web"
image: "writeas/writefreely:latest"
web:
build: .
volumes:
- "web-keys:/go/keys"
- "./config.ini:/go/config.ini"
networks:
- "internal_writefreely"
- "external_writefreely"
- "web-data:/go/src/app"
- "./config.ini.example:/go/src/app/config.ini"
ports:
- "8080:8080"
depends_on:
- "writefreely-db"
restart: unless-stopped
writefreely-db:
container_name: "writefreely-db"
image: "mariadb:latest"
volumes:
- "db-data:/var/lib/mysql/data"
networks:
- "internal_writefreely"
- writefreely
depends_on:
- db
restart: unless-stopped
db:
image: "mariadb:latest"
volumes:
- "./schema.sql:/tmp/schema.sql"
- db-data:/var/lib/mysql/data
networks:
- writefreely
environment:
- MYSQL_DATABASE=writefreely
- MYSQL_ROOT_PASSWORD=changeme
restart: unless-stopped
volumes:
web-data:
db-data:
networks:
writefreely:

477
email.go
View file

@ -1,477 +0,0 @@
/*
* 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
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2020 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -11,9 +11,8 @@
package writefreely
import (
"net/http"
"github.com/writeas/impart"
"net/http"
)
// Commonly returned HTTP errors
@ -37,8 +36,6 @@ var (
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."}
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
@ -47,13 +44,8 @@ var (
ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."}
ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."}
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
ErrRemoteUserNotFound = impart.HTTPError{http.StatusNotFound, "Remote user not found."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."}
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
)
// Post operation errors

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2019 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -14,13 +14,12 @@ import (
"archive/zip"
"bytes"
"encoding/csv"
"github.com/writeas/web-core/log"
"strings"
"time"
"github.com/writeas/web-core/log"
)
func exportPostsCSV(hostName string, u *User, posts *[]PublicPost) []byte {
func exportPostsCSV(u *User, posts *[]PublicPost) []byte {
var b bytes.Buffer
r := [][]string{
@ -30,16 +29,15 @@ func exportPostsCSV(hostName string, u *User, posts *[]PublicPost) []byte {
var blog string
if p.Collection != nil {
blog = p.Collection.Alias
p.Collection.hostName = hostName
}
f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(hostName), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)}
f := []string{p.ID, p.Slug.String, blog, p.CanonicalURL(), p.Created8601(), p.Title.String, strings.Replace(p.Content, "\n", "\\n", -1)}
r = append(r, f)
}
w := csv.NewWriter(&b)
w.WriteAll(r) // calls Flush internally
if err := w.Error(); err != nil {
log.Info("error writing csv: %v", err)
log.Info("error writing csv:", err)
}
return b.Bytes()
@ -100,17 +98,17 @@ func exportPostsZip(u *User, posts *[]PublicPost) []byte {
return b.Bytes()
}
func compileFullExport(app *App, u *User) *ExportUser {
func compileFullExport(app *app, u *User) *ExportUser {
exportUser := &ExportUser{
User: u,
}
colls, err := app.db.GetCollections(u, app.cfg.App.Host)
colls, err := app.db.GetCollections(u)
if err != nil {
log.Error("unable to fetch collections: %v", err)
}
posts, err := app.db.GetAnonymousPosts(u, 0)
posts, err := app.db.GetAnonymousPosts(u)
if err != nil {
log.Error("unable to fetch anon posts: %v", err)
}
@ -119,7 +117,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(&c, 0, true, false, true)
if err != nil {
log.Error("unable to get collection posts: %v", err)
}

45
feed.go
View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -12,16 +12,15 @@ package writefreely
import (
"fmt"
. "github.com/gorilla/feeds"
"github.com/gorilla/mux"
stripmd "github.com/writeas/go-strip-markdown"
"github.com/writeas/web-core/log"
"net/http"
"time"
"github.com/gorilla/feeds"
"github.com/gorilla/mux"
stripmd "github.com/writeas/go-strip-markdown/v2"
"github.com/writeas/web-core/log"
)
func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
func ViewFeed(app *app, w http.ResponseWriter, req *http.Request) error {
alias := collectionAliasFromReq(req)
// Display collection if this is a collection
@ -36,16 +35,6 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
return nil
}
silenced, err := app.db.IsUserSilenced(c.OwnerID)
if err != nil {
log.Error("view feed: get user: %v", err)
return ErrInternalGeneral
}
if silenced {
return ErrCollectionNotFound
}
c.hostName = app.cfg.App.Host
if c.IsPrivate() || c.IsProtected() {
return ErrCollectionNotFound
}
@ -65,9 +54,9 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
tag := mux.Vars(req)["tag"]
if tag != "" {
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
coll.Posts, _ = app.db.GetPostsTagged(c, tag, 1, false)
} else {
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false, "")
coll.Posts, _ = app.db.GetPosts(c, 1, false, true, false)
}
author := ""
@ -87,29 +76,25 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
siteURL += "tag:" + tag
}
feed := &feeds.Feed{
feed := &Feed{
Title: collectionTitle,
Link: &feeds.Link{Href: siteURL},
Link: &Link{Href: siteURL},
Description: coll.Description,
Author: &feeds.Author{author, ""},
Author: &Author{author, ""},
Created: time.Now(),
}
var title, permalink string
for _, p := range *coll.Posts {
// Add necessary path back to the web browser for Web Monetization if needed
p.Collection = coll.CollectionObj // augmentReadingDestination requires a populated Collection field
p.augmentReadingDestination()
// Create the item for the feed
title = p.PlainDisplayTitle()
permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String)
feed.Items = append(feed.Items, &feeds.Item{
feed.Items = append(feed.Items, &Item{
Id: fmt.Sprintf("%s%s", basePermalinkUrl, p.Slug.String),
Title: title,
Link: &feeds.Link{Href: permalink},
Link: &Link{Href: permalink},
Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
Content: string(p.HTMLContent),
Author: &feeds.Author{author, ""},
Content: applyMarkdown([]byte(p.Content), ""),
Author: &Author{author, ""},
Created: p.Created,
Updated: p.Updated,
})

151
go.mod
View file

@ -1,98 +1,81 @@
module github.com/writefreely/writefreely
module github.com/writeas/writefreely
require (
github.com/PuerkitoBio/goquery v1.8.1 // indirect
github.com/aymerick/douceur v0.2.0
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f // indirect
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966 // indirect
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
github.com/beevik/etree v1.1.0 // indirect
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737 // indirect
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/dustin/go-humanize v1.0.1
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.8.1
github.com/dustin/go-humanize v1.0.0
github.com/fatih/color v1.7.0
github.com/fatih/structs v1.1.0 // indirect
github.com/go-macaron/cache v0.0.0-20151013081102-561735312776 // indirect
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 // indirect
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193 // indirect
github.com/go-sql-driver/mysql v1.4.1
github.com/go-test/deep v1.0.1 // indirect
github.com/gobuffalo/envy v1.9.0 // indirect
github.com/gogits/gogs v0.11.86
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 // indirect
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09 // indirect
github.com/gogs/gogs v0.11.86 // indirect
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a // indirect
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // 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/gorilla/feeds v1.1.0
github.com/gorilla/mux v1.7.0
github.com/gorilla/schema v1.0.2
github.com/gorilla/sessions v1.1.3
github.com/guregu/null v3.4.0+incompatible
github.com/ikeikeikeike/go-sitemap-generator v1.0.1
github.com/imdario/mergo v0.3.7 // indirect
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect
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.21
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mitchellh/go-wordwrap v1.0.1
github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/manifoldco/promptui v0.3.2
github.com/mattn/go-colorable v0.1.0 // indirect
github.com/mattn/go-sqlite3 v1.10.0
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa // indirect
github.com/microcosm-cc/bluemonday v1.0.2
github.com/mitchellh/go-wordwrap v1.0.0
github.com/nicksnyder/go-i18n v1.10.0 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
github.com/onsi/ginkgo v1.16.4 // indirect
github.com/onsi/gomega v1.13.0 // indirect
github.com/pelletier/go-toml v1.2.0 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // 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/stretchr/testify v1.9.0
github.com/urfave/cli/v2 v2.27.4
github.com/stretchr/testify v1.3.0 // indirect
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
github.com/writeas/go-webfinger v1.1.0
github.com/writeas/go-strip-markdown v2.0.1+incompatible
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
github.com/writeas/httpsig v1.0.0
github.com/writeas/impart v1.1.1
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/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.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
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/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/gofrs/uuid v3.3.0+incompatible // indirect
github.com/gologme/log v1.2.0 // 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.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/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/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
github.com/writeas/impart v1.1.0
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
github.com/writeas/nerds v1.0.0
github.com/writeas/openssl-go v1.0.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
github.com/writeas/saturday v1.7.1
github.com/writeas/slug v1.2.0
github.com/writeas/web-core v1.0.0
github.com/writefreely/go-nodeinfo v1.2.0
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f // indirect
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 // indirect
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
google.golang.org/appengine v1.4.0 // indirect
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e // indirect
gopkg.in/clog.v1 v1.2.0 // indirect
gopkg.in/ini.v1 v1.41.0
gopkg.in/macaron.v1 v1.3.2 // indirect
gopkg.in/redis.v2 v2.3.2 // indirect
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
)
go 1.21

418
go.sum
View file

@ -1,171 +1,135 @@
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/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f h1:m1tYqjD/N0vF/S8s/ZKz/eccUr8RAAcrOK2MhXeTegA=
github.com/Unknwon/com v0.0.0-20181010210213-41959bdd855f/go.mod h1:KYCjqMOeHpNuTOiFQU6WEcTG7poCJrUs0YgyHNtn1no=
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966 h1:Mp8GNJ/tdTZIEdLdZfykEJaL3mTyEYrSzYNcdoQKpJk=
github.com/Unknwon/i18n v0.0.0-20171114194641-b64d33658966/go.mod h1:SFtfq0zFPsENI7DpE87QM2hcYu5QQ0fRdCgP+P1Hrqo=
github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU=
github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737 h1:rRISKWyXfVxvoa702s91Zl5oREZTrR3yv+tXrrX7G/g=
github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60=
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks=
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
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.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
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=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/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/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/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
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.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-macaron/cache v0.0.0-20151013081102-561735312776 h1:UYIHS1r0WotqB5cIa0PAiV0m6GzD9rDBcn4alp5JgCw=
github.com/go-macaron/cache v0.0.0-20151013081102-561735312776/go.mod h1:hHAsZm/oBZVcY+S7qdQL6Vbg5VrXF6RuKGuqsszt3Ok=
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191 h1:NjHlg70DuOkcAMqgt0+XA+NHwtu66MkTVVgR4fFWbcI=
github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw=
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193 h1:z/nqwd+ql/r6Q3QGnwNd6B89UjPytM0be5pDQV9TuWw=
github.com/go-macaron/session v0.0.0-20190131233854-0a0a789bf193/go.mod h1:ScEJm9Gk+ez5JJTml5WlBIqavAfuE5nF8e4Gvyz/X+A=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
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/gogits/gogs v0.11.86 h1:IujCpA+F/mYDXTcqdy593rl2donWakAWoL2HYZn7spw=
github.com/gogits/gogs v0.11.86/go.mod h1:H8FMbPPb+o/TgI6YnmQmT8nmEIHypXDau+f2CChYoCk=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09 h1:UdOSIHZpkYcajRbfebBYzFDsL3SuqObH3bvKYBqgKmI=
github.com/gogs/go-libravatar v0.0.0-20161120025154-cd1abbd55d09/go.mod h1:Zas3BtO88pk1cwUfEYlvnl/CRwh0ybDxRWSwRjG8I3w=
github.com/gogs/gogs v0.11.86 h1:D+dXuY/6XjJ2t74W/dxo7ogx5+xW05Va8sJiQSS4WXA=
github.com/gogs/gogs v0.11.86/go.mod h1:qlbvdn16XTC6q7eR+thjW+OLdN+mi2PBZ8KqVT39T88=
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a h1:8DZwxETOVWIinYxDK+i6L+rMb7eGATGaakD6ZucfHVk=
github.com/gogs/minwinsvc v0.0.0-20170301035411-95be6356811a/go.mod h1:TUIZ+29jodWQ8Gk6Pvtg4E09aMsc3C/VLZiVYfUhWQU=
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
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/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
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.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/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.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=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-multierror v1.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/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY=
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk=
github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA=
github.com/gorilla/schema v1.0.2/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.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
github.com/ikeikeikeike/go-sitemap-generator v1.0.1 h1:49Fn8gro/B12vCY8pf5/+/Jpr3kwB9TvP0MSymo69SY=
github.com/ikeikeikeike/go-sitemap-generator v1.0.1/go.mod h1:QI+zWsz6yQyxkG9LWNcnu0f7aiAE5tPdsZOsICgmd1c=
github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI=
github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts=
github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs=
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/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
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.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.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/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw=
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8=
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa h1:XvNrttGMJfVrUqblGju4IkjYXwx6l5OAAyjaIsydzsk=
github.com/mcuadros/go-version v0.0.0-20180611085657-6d5863ca60fa/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q=
github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q=
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=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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=
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
@ -173,161 +137,75 @@ 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.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/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c=
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk=
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=
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA=
github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA=
github.com/writeas/go-writeas/v2 v2.0.2 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk=
github.com/writeas/go-writeas/v2 v2.0.2/go.mod h1:9sjczQJKmru925fLzg0usrU1R1tE4vBmQtGnItUMR0M=
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o=
github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
github.com/writeas/monday v1.3.0 h1:h51wJ0DULXIDZ1w11zutLL7YCBRO5LznXISSzqVLZeA=
github.com/writeas/monday v1.3.0/go.mod h1:9/CdGLDdIeAvzvf4oeihX++PE/qXUT2+tUlPQKCfRWY=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU=
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431 h1:ruqL2u87k504PXkR/fC4DcfZyyHmCindlpjOQKmyOsY=
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431/go.mod h1:7+idL4Y4woF7MnUfNX2mvkaQ8nLIJXths2y5iYPtA3k=
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0=
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M=
github.com/writeas/web-core v1.0.0 h1:5VKkCakQgdKZcbfVKJXtRpc5VHrkflusCl/KRCPzpQ0=
github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE=
github.com/writefreely/go-nodeinfo v1.1.0 h1:dp/ieEu0/gTeNKFvJTYhzBBouyFn7aiWtWzkb8J1JLg=
github.com/writefreely/go-nodeinfo v1.1.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
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/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 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
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.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/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg=
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c=
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
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.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/net v0.0.0-20190206173232-65e2d4e15006 h1:bfLnR+k0tq5Lqt6dflRLcZiz6UaXCMt3vhYJ1l4FQ80=
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
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=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 h1:5SvYFrOM3W8Mexn9/oA44Ji7vhXAZQ9hiP+1Q/DMrWg=
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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.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=
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY=
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y=
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e h1:wGA78yza6bu/mWcc4QfBuIEHEtc06xdiU0X8sY36yUU=
gopkg.in/bufio.v1 v1.0.0-20140618132640-567b2bfa514e/go.mod h1:xsQCaysVCudhrYTfzYWe577fCe7Ceci+6qjO2Rdc0Z4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
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/clog.v1 v1.2.0 h1:BHfwHRNQy497iBNsRBassPixSAxRbn2z5KVkdBFbwxc=
gopkg.in/clog.v1 v1.2.0/go.mod h1:L6fgdpdhFgKX4eGuDvt+N6X2GwZE160NRrIHzvaF8ZM=
gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/macaron.v1 v1.3.2 h1:AvWIaPmwBUA87/OWzePkoxeaw6YJWDfBt1pDFPBnLf8=
gopkg.in/macaron.v1 v1.3.2/go.mod h1:PrsiawTWAGZs6wFbT5hlr7SQ2Ns9h7cUVtcUu4lQOVo=
gopkg.in/redis.v2 v2.3.2 h1:GPVIIB/JnL1wvfULefy3qXmPu1nfNu2d0yA09FHgwfs=
gopkg.in/redis.v2 v2.3.2/go.mod h1:4wl9PJ/CqzeHk3LVq1hNLHH8krm3+AXEgut4jVc++LU=
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 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
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=

167
gopher.go
View file

@ -1,167 +0,0 @@
/*
* Copyright © 2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package writefreely
import (
"bytes"
"fmt"
"io"
"net/url"
"regexp"
"strings"
"github.com/writeas/web-core/log"
"github.com/writefreely/go-gopher"
)
func initGopher(apper Apper) {
handler := NewWFHandler(apper)
gopher.HandleFunc("/", handler.Gopher(handleGopher))
log.Info("Serving on gopher://localhost:%d", apper.App().Config().Server.GopherPort)
gopher.ListenAndServe(fmt.Sprintf(":%d", apper.App().Config().Server.GopherPort), nil)
}
// Utility function to strip the URL from the hostname provided by app.cfg.App.Host
func stripHostProtocol(app *App) string {
u, err := url.Parse(app.cfg.App.Host)
if err != nil {
// Fall back to host, with scheme stripped
return string(regexp.MustCompile("^.*://").ReplaceAll([]byte(app.cfg.App.Host), []byte("")))
}
return u.Hostname()
}
func handleGopher(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
parts := strings.Split(r.Selector, "/")
if app.cfg.App.SingleUser {
if parts[1] != "" {
return handleGopherCollectionPost(app, w, r)
}
return handleGopherCollection(app, w, r)
}
// Show all public collections (a gopher Reader view, essentially)
if len(parts) == 3 {
return handleGopherCollection(app, w, r)
}
w.WriteInfo(fmt.Sprintf("Welcome to %s", app.cfg.App.SiteName))
colls, err := app.db.GetPublicCollections(app.cfg.App.Host)
if err != nil {
return err
}
for _, c := range *colls {
w.WriteItem(&gopher.Item{
Host: stripHostProtocol(app),
Port: app.cfg.Server.GopherPort,
Type: gopher.DIRECTORY,
Description: c.DisplayTitle(),
Selector: "/" + c.Alias + "/",
})
}
return w.End()
}
func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
var collAlias, slug string
var c *Collection
var err error
var baseSel = "/"
parts := strings.Split(r.Selector, "/")
if app.cfg.App.SingleUser {
// sanity check
slug = parts[1]
if slug != "" {
return handleGopherCollectionPost(app, w, r)
}
c, err = app.db.GetCollectionByID(1)
if err != nil {
return err
}
} else {
collAlias = parts[1]
slug = parts[2]
if slug != "" {
return handleGopherCollectionPost(app, w, r)
}
c, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
baseSel = "/" + c.Alias + "/"
}
c.hostName = app.cfg.App.Host
w.WriteInfo(c.DisplayTitle())
if c.Description != "" {
w.WriteInfo(c.Description)
}
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
if err != nil {
return err
}
for _, p := range *posts {
w.WriteItem(&gopher.Item{
Port: app.cfg.Server.GopherPort,
Host: stripHostProtocol(app),
Type: gopher.FILE,
Description: p.CreatedDate() + " - " + p.DisplayTitle(),
Selector: baseSel + p.Slug.String,
})
}
return w.End()
}
func handleGopherCollectionPost(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
var collAlias, slug string
var c *Collection
var err error
parts := strings.Split(r.Selector, "/")
if app.cfg.App.SingleUser {
slug = parts[1]
c, err = app.db.GetCollectionByID(1)
if err != nil {
return err
}
} else {
collAlias = parts[1]
slug = parts[2]
c, err = app.db.GetCollection(collAlias)
if err != nil {
return err
}
}
c.hostName = app.cfg.App.Host
p, err := app.db.GetPost(slug, c.ID)
if err != nil {
return err
}
b := bytes.Buffer{}
if p.Title.String != "" {
b.WriteString(p.Title.String + "\n")
}
b.WriteString(p.DisplayDate + "\n\n")
b.WriteString(p.Content)
io.Copy(w, &b)
return w.End()
}

540
handle.go
View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2021 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -23,60 +23,29 @@ import (
"github.com/gorilla/sessions"
"github.com/writeas/impart"
"github.com/writeas/web-core/log"
"github.com/writefreely/go-gopher"
"github.com/writefreely/writefreely/config"
"github.com/writefreely/writefreely/page"
"github.com/writeas/writefreely/page"
)
// UserLevel represents the required user level for accessing an endpoint
type UserLevel int
const (
UserLevelNoneType UserLevel = iota // user or not -- ignored
UserLevelOptionalType // user or not -- object fetched if user
UserLevelNoneRequiredType // non-user (required)
UserLevelUserType // user (required)
UserLevelNone UserLevel = iota // user or not -- ignored
UserLevelOptional // user or not -- object fetched if user
UserLevelNoneRequired // non-user (required)
UserLevelUser // user (required)
)
func UserLevelNone(cfg *config.Config) UserLevel {
return UserLevelNoneType
}
func UserLevelOptional(cfg *config.Config) UserLevel {
return UserLevelOptionalType
}
func UserLevelNoneRequired(cfg *config.Config) UserLevel {
return UserLevelNoneRequiredType
}
func UserLevelUser(cfg *config.Config) UserLevel {
return UserLevelUserType
}
// UserLevelReader returns the permission level required for any route where
// users can read published content.
func UserLevelReader(cfg *config.Config) UserLevel {
if cfg.App.Private {
return UserLevelUserType
}
return UserLevelOptionalType
}
type (
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error
gopherFunc func(app *App, w gopher.ResponseWriter, r *gopher.Request) error
userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error
dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
authFunc func(app *App, r *http.Request) (*User, error)
UserLevelFunc func(cfg *config.Config) UserLevel
handlerFunc func(app *app, w http.ResponseWriter, r *http.Request) error
userHandlerFunc func(app *app, u *User, w http.ResponseWriter, r *http.Request) error
dataHandlerFunc func(app *app, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
authFunc func(app *app, r *http.Request) (*User, error)
)
type Handler struct {
errors *ErrorPages
sessionStore sessions.Store
app Apper
sessionStore *sessions.CookieStore
app *app
}
// ErrorPages hold template HTML error pages for displaying errors to the user.
@ -85,42 +54,26 @@ type ErrorPages struct {
NotFound *template.Template
Gone *template.Template
InternalServerError *template.Template
UnavailableError *template.Template
Blank *template.Template
}
// NewHandler returns a new Handler instance, using the given StaticPage data,
// and saving alias to the application's CookieStore.
func NewHandler(apper Apper) *Handler {
func NewHandler(app *app) *Handler {
h := &Handler{
errors: &ErrorPages{
NotFound: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")),
Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>503</title></head><body><p>Service is temporarily unavailable.</p></body></html>{{end}}")),
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
},
sessionStore: apper.App().SessionStore(),
app: apper,
sessionStore: app.sessionStore,
app: app,
}
return h
}
// NewWFHandler returns a new Handler instance, using WriteFreely template files.
// You MUST call writefreely.InitTemplates() before this.
func NewWFHandler(apper Apper) *Handler {
h := NewHandler(apper)
h.SetErrorPages(&ErrorPages{
NotFound: pages["404-general.tmpl"],
Gone: pages["410.tmpl"],
InternalServerError: pages["500.tmpl"],
UnavailableError: pages["503.tmpl"],
Blank: pages["blank.tmpl"],
})
return h
}
// SetErrorPages sets the given set of ErrorPages as templates for any errors
// that come up.
func (h *Handler) SetErrorPages(e *ErrorPages) {
@ -138,31 +91,25 @@ func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = http.StatusInternalServerError
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
u := getUserSession(h.app.App(), r)
u := getUserSession(h.app, r)
if u == nil {
err := ErrNotLoggedIn
status = err.Status
return err
}
err := f(h.app.App(), u, w, r)
err := f(h.app, u, w, r)
if err == nil {
status = http.StatusOK
} else if impErr, ok := err.(impart.HTTPError); ok {
status = impErr.Status
if impErr == ErrUserNotFound {
log.Info("Logged-in user not found. Logging out.")
sendRedirect(w, http.StatusFound, "/me/logout?to="+h.app.App().cfg.App.LandingPath())
// Reset err so handleHTTPError does nothing
err = nil
}
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
@ -182,52 +129,14 @@ func (h *Handler) Admin(f userHandlerFunc) http.HandlerFunc {
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = http.StatusInternalServerError
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent()))
}()
u := getUserSession(h.app.App(), r)
if u == nil || !u.IsAdmin() {
err := impart.HTTPError{http.StatusNotFound, ""}
status = err.Status
return err
}
err := f(h.app.App(), u, w, r)
if err == nil {
status = http.StatusOK
} else if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
return err
}())
}
}
// AdminApper handles requests on /admin routes that require an Apper.
func (h *Handler) AdminApper(f userApperHandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
status = http.StatusInternalServerError
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
u := getUserSession(h.app.App(), r)
u := getUserSession(h.app, r)
if u == nil || !u.IsAdmin() {
err := impart.HTTPError{http.StatusNotFound, ""}
status = err.Status
@ -248,65 +157,18 @@ func (h *Handler) AdminApper(f userApperHandlerFunc) http.HandlerFunc {
}
}
func apiAuth(app *App, r *http.Request) (*User, error) {
// Authorize user from Authorization header
t := r.Header.Get("Authorization")
if t == "" {
return nil, ErrNoAccessToken
}
u := &User{ID: app.db.GetUserID(t)}
if u.ID == -1 {
return nil, ErrBadAccessToken
}
return u, nil
}
// optionalAPIAuth is used for endpoints that accept authenticated requests via
// Authorization header or cookie, unlike apiAuth. It returns a different err
// in the case where no Authorization header is present.
func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
// Authorize user from Authorization header
t := r.Header.Get("Authorization")
if t == "" {
return nil, ErrNotLoggedIn
}
u := &User{ID: app.db.GetUserID(t)}
if u.ID == -1 {
return nil, ErrBadAccessToken
}
return u, nil
}
func webAuth(app *App, r *http.Request) (*User, error) {
u := getUserSession(app, r)
if u == nil {
return nil, ErrNotLoggedIn
}
return u, nil
}
// UserAPI handles requests made in the API by the authenticated user.
// This provides user-friendly HTML pages and actions that work in the browser.
func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
return h.UserAll(false, f, apiAuth)
}
// UserWebAPI handles endpoints that accept a user authorized either via the web (cookies) or an Authorization header.
func (h *Handler) UserWebAPI(f userHandlerFunc) http.HandlerFunc {
return h.UserAll(false, f, func(app *App, r *http.Request) (*User, error) {
// Authorize user via cookies
u := getUserSession(app, r)
if u != nil {
return u, nil
return h.UserAll(false, f, func(app *app, r *http.Request) (*User, error) {
// Authorize user from Authorization header
t := r.Header.Get("Authorization")
if t == "" {
return nil, ErrNoAccessToken
}
// Fall back to access token, since user isn't logged in via web
var err error
u, err = apiAuth(app, r)
if err != nil {
return nil, err
u := &User{ID: app.db.GetUserID(t)}
if u.ID == -1 {
return nil, ErrBadAccessToken
}
return u, nil
@ -326,10 +188,10 @@ func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerF
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
u, err := a(h.app.App(), r)
u, err := a(h.app, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
@ -339,7 +201,7 @@ func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerF
return err
}
err = f(h.app.App(), u, w, r)
err = f(h.app, u, w, r)
if err == nil {
status = 200
} else if err, ok := err.(impart.HTTPError); ok {
@ -360,7 +222,7 @@ func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerF
}
func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
return func(app *App, w http.ResponseWriter, r *http.Request) error {
return func(app *app, w http.ResponseWriter, r *http.Request) error {
err := f(app, w, r)
if err != nil {
if ie, ok := err.(impart.HTTPError); ok {
@ -377,7 +239,7 @@ func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
}
func (h *Handler) Page(n string) http.HandlerFunc {
return h.Web(func(app *App, w http.ResponseWriter, r *http.Request) error {
return h.Web(func(app *app, w http.ResponseWriter, r *http.Request) error {
t, ok := pages[n]
if !ok {
return impart.HTTPError{http.StatusNotFound, "Page not found."}
@ -393,7 +255,7 @@ func (h *Handler) Page(n string) http.HandlerFunc {
}, UserLevelOptional)
}
func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: factor out this logic shared with Web()
h.handleHTTPError(w, r, func() error {
@ -402,36 +264,36 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
defer func() {
if e := recover(); e != nil {
u := getUserSession(h.app.App(), r)
u := getUserSession(h.app, r)
username := "None"
if u != nil {
username = u.Username
}
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
var session *sessions.Session
var err error
if ul(h.app.App().cfg) != UserLevelNoneType {
if ul != UserLevelNone {
session, err = h.sessionStore.Get(r, cookieName)
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
if ul == UserLevelNoneRequired && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
} else if ul == UserLevelUser && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
@ -440,13 +302,13 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
}
// TODO: pass User object to function
err = f(h.app.App(), w, r)
err = f(h.app, w, r)
if err == nil {
status = 200
} else if httpErr, ok := err.(impart.HTTPError); ok {
status = httpErr.Status
if status < 300 || status > 399 {
addSessionFlash(h.app.App(), w, r, httpErr.Message, session)
addSessionFlash(h.app, w, r, httpErr.Message, session)
return impart.HTTPError{http.StatusFound, r.Referer()}
}
} else {
@ -457,7 +319,7 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
log.Error(e)
}
log.Info("Web handler internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
@ -466,25 +328,9 @@ func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
}
}
func (h *Handler) CollectionPostOrStatic(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, ".") && !isRaw(r) {
start := time.Now()
status := 200
defer func() {
log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
// Serve static file
h.app.App().shttp.ServeHTTP(w, r)
return
}
h.Web(viewCollectionPost, UserLevelReader)(w, r)
}
// Web handles requests made in the web application. This provides user-
// friendly HTML pages and actions that work in the browser.
func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
func (h *Handler) Web(f handlerFunc, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
@ -492,35 +338,35 @@ func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
defer func() {
if e := recover(); e != nil {
u := getUserSession(h.app.App(), r)
u := getUserSession(h.app, r)
username := "None"
if u != nil {
username = u.Username
}
log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
log.Info("Web deferred internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
if ul(h.app.App().cfg) != UserLevelNoneType {
if ul != UserLevelNone {
session, err := h.sessionStore.Get(r, cookieName)
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
if ul == UserLevelNoneRequired && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
} else if ul == UserLevelUser && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
@ -529,7 +375,7 @@ func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
}
// TODO: pass User object to function
err := f(h.app.App(), w, r)
err := f(h.app, w, r)
if err == nil {
status = 200
} else if httpErr, ok := err.(impart.HTTPError); ok {
@ -538,7 +384,7 @@ func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
e := fmt.Sprintf("[Web handler] 500: %v", err)
log.Error(e)
log.Info("Web internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
@ -561,12 +407,12 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
// TODO: do any needed authentication
err := f(h.app.App(), w, r)
err := f(h.app, w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
@ -580,131 +426,7 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
}
}
func (h *Handler) PlainTextAPI(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleTextError(w, r, func() error {
// TODO: return correct "success" status
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s:\n%s", e, debug.Stack())
status = http.StatusInternalServerError
w.WriteHeader(status)
fmt.Fprintf(w, "Something didn't work quite right. The robots have alerted the humans.")
}
log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\" \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent(), r.Host))
}()
err := f(h.app.App(), w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = http.StatusInternalServerError
}
}
return err
}())
}
}
func (h *Handler) OAuth(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleOAuthError(w, r, func() error {
// TODO: return correct "success" status
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s:\n%s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
err := f(h.app.App(), w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
}
return err
}())
}
}
func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleError(w, r, func() error {
status := 200
start := time.Now()
defer func() {
if e := recover(); e != nil {
log.Error("%s:\n%s", e, debug.Stack())
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
}()
// Allow any origin, as public endpoints are handled in here
w.Header().Set("Access-Control-Allow-Origin", "*")
if h.app.App().cfg.App.Private {
// This instance is private, so ensure it's being accessed by a valid user
// Check if authenticated with an access token
_, apiErr := optionalAPIAuth(h.app.App(), r)
if apiErr != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
if apiErr == ErrNotLoggedIn {
// Fall back to web auth since there was no access token given
_, err := webAuth(h.app.App(), r)
if err != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
} else {
return apiErr
}
}
}
err := f(h.app.App(), w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
}
return err
}())
}
}
func (h *Handler) Download(f dataHandlerFunc, ul UserLevelFunc) http.HandlerFunc {
func (h *Handler) Download(f dataHandlerFunc, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
var status int
@ -712,14 +434,14 @@ func (h *Handler) Download(f dataHandlerFunc, ul UserLevelFunc) http.HandlerFunc
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
data, filename, err := f(h.app.App(), w, r)
data, filename, err := f(h.app, w, r)
if err != nil {
if err, ok := err.(impart.HTTPError); ok {
status = err.Status
@ -749,27 +471,27 @@ func (h *Handler) Download(f dataHandlerFunc, ul UserLevelFunc) http.HandlerFunc
}
}
func (h *Handler) Redirect(url string, ul UserLevelFunc) http.HandlerFunc {
func (h *Handler) Redirect(url string, ul UserLevel) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h.handleHTTPError(w, r, func() error {
start := time.Now()
var status int
if ul(h.app.App().cfg) != UserLevelNoneType {
if ul != UserLevelNone {
session, err := h.sessionStore.Get(r, cookieName)
if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
// Cookie is required, but we can ignore this error
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
}
_, gotUser := session.Values[cookieUserVal].(*User)
if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
if ul == UserLevelNoneRequired && gotUser {
to := correctPageFromLoginAttempt(r)
log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
err := impart.HTTPError{http.StatusFound, to}
status = err.Status
return err
} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
} else if ul == UserLevelUser && !gotUser {
log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
err := ErrNotLoggedIn
status = err.Status
@ -779,7 +501,7 @@ func (h *Handler) Redirect(url string, ul UserLevelFunc) http.HandlerFunc {
status = sendRedirect(w, http.StatusFound, url)
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
return nil
}())
@ -808,7 +530,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
page.StaticPage
Content *template.HTML
}{
StaticPage: pageForReq(h.app.App(), r),
StaticPage: pageForReq(h.app, r),
}
if err.Message != "" {
co := template.HTML(err.Message)
@ -818,20 +540,12 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
return
} else if err.Status == http.StatusNotFound {
w.WriteHeader(err.Status)
if IsActivityPubRequest(r) {
// This is a fediverse request; simply return the header
return
}
h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app, r))
return
} else if err.Status == http.StatusInternalServerError {
w.WriteHeader(err.Status)
log.Info("handleHTTPErorr internal error render")
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
return
} else if err.Status == http.StatusServiceUnavailable {
w.WriteHeader(err.Status)
h.errors.UnavailableError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
return
} else if err.Status == http.StatusAccepted {
impart.WriteSuccess(w, "", err.Status)
@ -842,7 +556,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
Title string
Content template.HTML
}{
pageForReq(h.app.App(), r),
pageForReq(h.app, r),
fmt.Sprintf("Uh oh (%d)", err.Status),
template.HTML(fmt.Sprintf("<p style=\"text-align: center\" class=\"introduction\">%s</p>", err.Message)),
}
@ -873,50 +587,11 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
return
}
if IsJSON(r) {
if IsJSON(r.Header.Get("Content-Type")) {
impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
return
}
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
}
func (h *Handler) handleTextError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
if err, ok := err.(impart.HTTPError); ok {
if err.Status >= 300 && err.Status < 400 {
sendRedirect(w, err.Status, err.Message)
return
}
w.WriteHeader(err.Status)
fmt.Fprintf(w, http.StatusText(err.Status))
return
}
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "This is an unhelpful error message for a miscellaneous internal error.")
}
func (h *Handler) handleOAuthError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
if err, ok := err.(impart.HTTPError); ok {
if err.Status >= 300 && err.Status < 400 {
sendRedirect(w, err.Status, err.Message)
return
}
impart.WriteOAuthError(w, err)
return
}
impart.WriteOAuthError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
return
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
}
func correctPageFromLoginAttempt(r *http.Request) string {
@ -938,42 +613,14 @@ func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
defer func() {
if e := recover(); e != nil {
log.Error("Handler.LogHandlerFunc\n\n%s: %s", e, debug.Stack())
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app, r))
status = 500
}
// TODO: log actual status code returned
log.Info(h.app.ReqLog(r, status, time.Since(start)))
log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
}()
if h.app.App().cfg.App.Private {
// This instance is private, so ensure it's being accessed by a valid user
// Check if authenticated with an access token
_, apiErr := optionalAPIAuth(h.app.App(), r)
if apiErr != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
if apiErr == ErrNotLoggedIn {
// Fall back to web auth since there was no access token given
_, err := webAuth(h.app.App(), r)
if err != nil {
if err, ok := apiErr.(impart.HTTPError); ok {
status = err.Status
} else {
status = 500
}
return err
}
} else {
return apiErr
}
}
}
f(w, r)
return nil
@ -981,33 +628,8 @@ func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
}
}
func (h *Handler) Gopher(f gopherFunc) gopher.HandlerFunc {
return func(w gopher.ResponseWriter, r *gopher.Request) {
defer func() {
if e := recover(); e != nil {
log.Error("%s: %s", e, debug.Stack())
w.WriteError("An internal error occurred")
}
log.Info("gopher: %s", r.Selector)
}()
err := f(h.app.App(), w, r)
if err != nil {
log.Error("failed: %s", err)
w.WriteError("the page failed for some reason (see logs)")
}
}
}
func sendRedirect(w http.ResponseWriter, code int, location string) int {
w.Header().Set("Location", location)
w.WriteHeader(code)
return code
}
func cacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
next.ServeHTTP(w, r)
})
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2019 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -15,7 +15,7 @@ import (
"net/http"
)
func handleViewHostMeta(app *App, w http.ResponseWriter, r *http.Request) error {
func handleViewHostMeta(app *app, w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Server", serverSoftware)
w.Header().Set("Content-Type", "application/xrd+xml; charset=utf-8")

119
import.go Normal file
View file

@ -0,0 +1,119 @@
package writefreely
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
)
type importUser struct {
Username string `json:"username"`
HasPass bool `json:"has_pass"`
Email string `json:"email"`
Created string `json:"created"`
Collections []importCollection `json:"collections"`
}
type importCollection struct {
Alias string `json:"alias"`
Title string `json:"title"`
Description string `json:"description"`
StyleSheet string `json:"style_sheet"`
Public bool `json:"public"`
Views int `json:"views"`
URL string `json:"url"`
Total int `json:"total_posts"`
Posts []importPost `json:"posts"`
}
type importPost struct {
ID string `json:"id"`
Slug string `json:"slug"`
Appearance string `json:"appearance"`
Language string `json:"language"`
Rtl bool `json:"rtl"`
Created string `json:"created"`
Updated string `json:"updated"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags"`
Views int `json:"views"`
}
func jsonReader() {
// Open the jsonFile
jsonFile, err := os.Open("skye-201905250022.json")
// If os.Open returns an error then handle it
if err != nil {
fmt.Println(err)
}
fmt.Println("Successfully Opened users.json")
// Defer the closing of our jsonFile so it can be parsed later
defer jsonFile.Close()
// Read the opened xmlFile as a byte array.
byteValue, _ := ioutil.ReadAll(jsonFile)
// Initialize the collections array
var u importUser
// Unmarshal the byteArray which contains the
// jsonFile's content into 'importUser'
json.Unmarshal(byteValue, &u)
fmt.Printf("Top level data is: %+v", u)
fmt.Println("Collection data is: ")
for _, c := range u.Collections {
fmt.Println(c)
}
fmt.Println("Posts data are: ")
for _, coll := range u.Collections {
for _, p := range coll.Posts {
fmt.Println(p)
}
}
return
// for _, p := range u.Collections[0].Posts {
// fmt.Println(p.ID)
// }
// we iterate through every user within our users array and
// print out the user Type, their name, and their facebook url
// as just an example
// for i := 0; i < len(users.Users); i++ {
// fmt.Println("User Type: " + users.Users[i].Type)
// fmt.Println("User Age: " + strconv.Itoa(users.Users[i].Age))
// fmt.Println("User Name: " + users.Users[i].Name)
// fmt.Println("Facebook Url: " + users.Users[i].Social.Facebook)
// }
}
// func zipreader(src string) ([]string, error) {
// // Open a zip archive for reading.
// r, err := zip.OpenReader("testdata/readme.zip")
// if err != nil {
// log.Fatal(err)
// }
// defer r.Close()
// // Iterate through the files in the archive,
// // printing some of their contents.
// for _, f := range r.File {
// fmt.Printf("Contents of %s:\n", f.Name)
// rc, err := f.Open()
// if err != nil {
// log.Fatal(err)
// }
// _, err = io.CopyN(os.Stdout, rc, 68)
// if err != nil {
// log.Fatal(err)
// }
// rc.Close()
// fmt.Println()
// }
// return
// }

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2019-2021 Musing Studio LLC.
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -12,16 +12,15 @@ package writefreely
import (
"database/sql"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/nerds/store"
"github.com/writeas/web-core/log"
"github.com/writeas/writefreely/page"
"html/template"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/writeas/impart"
"github.com/writeas/web-core/id"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/page"
)
type Invite struct {
@ -42,23 +41,11 @@ func (i Invite) Expired() bool {
return i.Expires != nil && i.Expires.Before(time.Now())
}
func (i Invite) Active(db *datastore) bool {
if i.Expired() {
return false
}
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
return false
}
}
return true
}
func (i Invite) ExpiresFriendly() string {
return i.Expires.Format("January 2, 2006, 3:04 PM")
}
func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleViewUserInvites(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
// Don't show page if instance doesn't allow it
if !(app.cfg.App.UserInvites != "" && (u.IsAdmin() || app.cfg.App.UserInvites != "admin")) {
return impart.HTTPError{http.StatusNotFound, ""}
@ -68,22 +55,12 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
p := struct {
*UserPage
Invites *[]Invite
Silenced bool
Invites *[]Invite
}{
UserPage: NewUserPage(app, r, u, "Invite People", f),
}
var err error
p.Silenced, err = app.db.IsUserSilenced(u.ID)
if err != nil {
if err == ErrUserNotFound {
return err
}
log.Error("view invites: %v", err)
}
p.Invites, err = app.db.GetUserInvites(u.ID)
if err != nil {
return err
@ -96,14 +73,10 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
return nil
}
func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
func handleCreateUserInvite(app *app, u *User, w http.ResponseWriter, r *http.Request) error {
muVal := r.FormValue("uses")
expVal := r.FormValue("expires")
if u.IsSilenced() {
return ErrUserSilenced
}
var err error
var maxUses int
if muVal != "0" {
@ -124,7 +97,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
expDate = &ed
}
inviteID := id.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate)
if err != nil {
return err
@ -133,7 +106,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
return impart.HTTPError{http.StatusFound, "/me/invites"}
}
func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
func handleViewInvite(app *app, w http.ResponseWriter, r *http.Request) error {
inviteCode := mux.Vars(r)["code"]
i, err := app.db.GetUserInvite(inviteCode)
@ -141,54 +114,25 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
return err
}
expired := i.Expired()
if !expired && i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
// Invite has a max-use number, so check if we're past that limit
i.uses = app.db.GetUsersInvitedCount(inviteCode)
expired = i.uses >= i.MaxUses.Int64
}
if u := getUserSession(app, r); u != nil {
// check if invite belongs to another user
// error can be ignored as not important in this case
if ownInvite, _ := app.db.IsUsersInvite(inviteCode, u.ID); !ownInvite {
addSessionFlash(app, w, r, "You're already registered and logged in.", nil)
// show homepage
return impart.HTTPError{http.StatusFound, "/me/settings"}
}
// show invite instructions
p := struct {
*UserPage
Invite *Invite
Expired bool
}{
UserPage: NewUserPage(app, r, u, "Invite to "+app.cfg.App.SiteName, nil),
Invite: i,
Expired: expired,
}
showUserPage(w, "invite-help", p)
return nil
}
p := struct {
page.StaticPage
*OAuthButtons
Error string
Flashes []template.HTML
Invite string
}{
StaticPage: pageForReq(app, r),
OAuthButtons: NewOAuthButtons(app.cfg),
Invite: inviteCode,
StaticPage: pageForReq(app, r),
Invite: inviteCode,
}
if expired {
if i.Expired() {
p.Error = "This invite link has expired."
}
// Tell search engines not to index invite links
w.Header().Set("X-Robots-Tag", "noindex")
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
if c := app.db.GetUsersInvitedCount(inviteCode); c >= i.MaxUses.Int64 {
p.Error = "This invite link has expired."
}
}
// Get error messages
session, err := app.sessionStore.Get(r, cookieName)

72
jobs.go
View file

@ -1,72 +0,0 @@
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
}

View file

@ -1,69 +0,0 @@
/*
* 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 key holds application keys and utilities around generating them.
package key
import (
"crypto/rand"
)
const (
EncKeysBytes = 32
)
type Keychain struct {
EmailKey, CookieAuthKey, CookieKey, CSRFKey []byte
}
// GenerateKeys generates necessary keys for the app on the given Keychain,
// skipping any that already exist.
func (keys *Keychain) GenerateKeys() error {
// Generate keys only if they don't already exist
// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
var err, keyErrs error
if len(keys.EmailKey) == 0 {
keys.EmailKey, err = GenerateBytes(EncKeysBytes)
if err != nil {
keyErrs = err
}
}
if len(keys.CookieAuthKey) == 0 {
keys.CookieAuthKey, err = GenerateBytes(EncKeysBytes)
if err != nil {
keyErrs = err
}
}
if len(keys.CookieKey) == 0 {
keys.CookieKey, err = GenerateBytes(EncKeysBytes)
if err != nil {
keyErrs = err
}
}
if len(keys.CSRFKey) == 0 {
keys.CSRFKey, err = GenerateBytes(EncKeysBytes)
if err != nil {
keyErrs = err
}
}
return keyErrs
}
// GenerateBytes returns securely generated random bytes.
func GenerateBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}

69
keys.go
View file

@ -1,5 +1,5 @@
/*
* Copyright © 2018-2019, 2021 Musing Studio LLC.
* Copyright © 2018 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -11,38 +11,64 @@
package writefreely
import (
"crypto/rand"
"github.com/writeas/web-core/log"
"github.com/writefreely/writefreely/key"
"io/ioutil"
"os"
"path/filepath"
)
const (
keysDir = "keys"
encKeysBytes = 32
)
var (
emailKeyPath = filepath.Join(keysDir, "email.aes256")
cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256")
cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256")
csrfKeyPath = filepath.Join(keysDir, "csrf.aes256")
)
// InitKeys loads encryption keys into memory via the given Apper interface
func InitKeys(apper Apper) error {
log.Info("Loading encryption keys...")
err := apper.LoadKeys()
if err != nil {
return err
}
return nil
type keychain struct {
emailKey, cookieAuthKey, cookieKey []byte
}
func initKeyPaths(app *App) {
func initKeyPaths(app *app) {
emailKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, emailKeyPath)
cookieAuthKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieAuthKeyPath)
cookieKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieKeyPath)
csrfKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, csrfKeyPath)
}
func initKeys(app *app) error {
var err error
app.keys = &keychain{}
if debugging {
log.Info(" %s", emailKeyPath)
}
app.keys.emailKey, err = ioutil.ReadFile(emailKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieAuthKeyPath)
}
app.keys.cookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
if err != nil {
return err
}
if debugging {
log.Info(" %s", cookieKeyPath)
}
app.keys.cookieKey, err = ioutil.ReadFile(cookieKeyPath)
if err != nil {
return err
}
return nil
}
// generateKey generates a key at the given path used for the encryption of
@ -51,7 +77,7 @@ func initKeyPaths(app *App) {
func generateKey(path string) error {
// Check if key file exists
if _, err := os.Stat(path); err == nil {
log.Info("%s already exists. rm the file if you understand the consequences.", path)
log.Info("%s already exists. rm the file if you understand the consquences.", path)
return nil
} else if !os.IsNotExist(err) {
log.Error("%s", err)
@ -59,12 +85,12 @@ func generateKey(path string) error {
}
log.Info("Generating %s.", path)
b, err := key.GenerateBytes(key.EncKeysBytes)
b, err := generateBytes(encKeysBytes)
if err != nil {
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
return err
}
err = os.WriteFile(path, b, 0600)
err = ioutil.WriteFile(path, b, 0600)
if err != nil {
log.Error("FAILED writing file: %s", err)
return err
@ -72,3 +98,14 @@ func generateKey(path string) error {
log.Info("Success.")
return nil
}
// generateBytes returns securely generated random bytes.
func generateBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}

View file

@ -1,11 +1,20 @@
ifeq ($(shell which lessc),/usr/bin/lessc)
LESSC=/usr/bin/lessc
else ifeq ($(shell which lessc),/usr/local/bin/lessc)
LESSC=/usr/local/bin/lessc
else ifeq ($(shell which lessc),/bin/lessc)
LESSC=/bin/lessc
else
LESSC=node_modules/.bin/lessc
endif
export LESSC
CSSDIR=../static/css/
all :
@command -v lessc >/dev/null 2>&1 || { echo >&2 "lessc is not installed, please run: make install or: less/install-less.sh"; exit 1; }
lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css
lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css
lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css
lessc prose.less --clean-css="--s1 --advanced" $(CSSDIR)prose.css
$(LESSC) app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css
$(LESSC) fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css
$(LESSC) icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css
install :
./install-less.sh

View file

@ -13,38 +13,19 @@ nav#admin {
display: block;
margin: 0.5em 0;
a {
margin-left: 0;
.rounded(.25em);
border: 0;
&.selected {
background: #dedede;
font-weight: bold;
.blip {
color: black;
}
color: @primary;
&:first-child {
margin-left: 0;
}
&.selected {
font-weight: bold;
}
}
.blip {
font-weight: bold;
}
}
.pager {
display: flex;
justify-content: center;
&:not(.pages) {
display: block;
margin: 0.5em 0;
a {
margin-left: 0;
.rounded(.25em);
&+a {
margin-left: 0.5em;
}
}
}
a {
color: #333;
font-family: @sansFont;
@ -60,69 +41,4 @@ 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 {
.btn {
font-family: @sansFont;
font-size: 0.86em;
}
}
.features {
margin: 1em 0;
div {
&:first-child {
font-weight: bold;
}
&+div {
padding-left: 1em;
}
p {
font-weight: normal;
margin: 0.5rem 0;
font-size: 0.86em;
color: #666;
}
}
}
@media (max-width: 600px) {
div.row.features {
align-items: start;
}
.features div + div {
padding-left: 0;
}
}

View file

@ -5,8 +5,6 @@
@import "post-temp";
@import "effects";
@import "admin";
@import "login";
@import "pages/error";
@import "resources";
@import "lib/elements";
@import "lib/material";

View file

@ -1,11 +1,22 @@
@primary: rgb(114, 120, 191);
@secondary: rgb(114, 191, 133);
@subheaders: #444;
@headerTextColor: black;
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
@dangerCol: #e21d27;
@errUrgentCol: #ecc63c;
@proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238);
body {
font-family: @serifFont;
font-size-adjust: 0.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: white;
color: #111;
h1, header h2 {
a {
color: @headerTextColor;
@ -69,7 +80,7 @@ body {
font-size: 1.5em;
}
h2 {
font-size: 1.4em;
font-size: 1.17em;
}
}
@ -96,13 +107,9 @@ body {
code {
.article-code;
}
img, video, audio {
img, video {
max-width: 100%;
}
audio {
width: 100%;
white-space: initial;
}
pre {
.code-block;
@ -210,10 +217,6 @@ body {
pre {
line-height: 1.5;
}
.flash {
text-align: center;
margin-bottom: 4em;
}
}
&#subpage {
#wrapper {
@ -249,8 +252,6 @@ body {
margin-bottom: 0.25em;
&+time {
display: block;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
time {
@ -397,39 +398,6 @@ body {
}
}
img {
&.paid {
height: 0.86em;
vertical-align: middle;
margin-bottom: 0.1em;
}
}
nav#full-nav {
margin: 0;
.left-side {
display: inline-block;
a:first-child {
margin-left: 0;
}
}
.right-side {
float: right;
}
}
nav#full-nav a.simple-btn, .tool button {
font-family: @sansFont;
border: 1px solid #ccc !important;
padding: .5rem 1rem;
margin: 0;
.rounded(.25em);
text-decoration: none;
}
.post-title {
a {
&:link {
@ -516,20 +484,13 @@ abbr {
body#collection article p, body#subpage article p {
.article-p;
}
pre, body#post article, #post .alert, #subpage .alert, body#collection article, body#subpage article, body#subpage #wrapper h1 {
pre, body#post article, body#collection article, body#subpage article, body#subpage #wrapper h1 {
max-width: 40rem;
margin: 0 auto;
}
#collection header .alert, #post .alert, #subpage .alert {
margin-bottom: 1em;
p {
text-align: left;
line-height: 1.5;
}
}
textarea, input#title, pre, body#post article, body#collection article p {
textarea, pre, body#post article, body#collection article p {
&.norm, &.sans, &.wrap {
line-height: 1.5;
line-height: 1.4em;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */
@ -537,7 +498,7 @@ textarea, input#title, pre, body#post article, body#collection article p {
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
textarea, input#title, pre, body#post article, body#collection article, body#subpage article, span, .font {
textarea, pre, body#post article, body#collection article, body#subpage article, span, .font {
&.norm {
font-family: @serifFont;
}
@ -605,6 +566,26 @@ p {
}
}
.formContainer {
// border-radius: 10px 10px 10px 10px;
// -moz-border-radius: 10px 10px 10px 10px;
// -webkit-border-radius: 10px 10px 10px 10px;
border-top: 1px solid #ccc;
margin-bottom: 2em;
padding: 1em 0em 0.5em 1em;
}
.import {
& input {
margin-bottom: 0.5em;
}
& input.fileInput {
padding-left: 0;
width: 70%;
}
}
table.classy {
width: 95%;
border-collapse: collapse;
@ -639,30 +620,10 @@ table.classy {
}
}
article table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
th {
border-width: 1px 1px 2px 1px;
border-style: solid;
border-color: #ccc;
}
td {
border-width: 0 1px 1px 1px;
border-style: solid;
border-color: #ccc;
padding: .25rem .5rem;
}
}
body#collection article, body#subpage article {
padding-top: 0;
padding-bottom: 0;
.book {
h2 {
font-size: 1.4em;
}
a.hidden.action {
color: #666;
float: right;
@ -672,26 +633,6 @@ 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;
@ -719,22 +660,20 @@ table.downloads {
select.inputform, textarea.inputform {
border: 1px solid #999;
background: white;
}
input, button, select.inputform, textarea.inputform, a.btn {
input, button, select.inputform, textarea.inputform {
padding: 0.5em;
font-family: @serifFont;
font-size: 100%;
.rounded(.25em);
&[type=submit], &.submit, &.cta {
&[type=submit], &.submit {
border: 1px solid @primary;
background: @primary;
color: white;
.transition(0.2s);
&:hover {
background-color: lighten(@primary, 3%);
text-decoration: none;
}
&:disabled {
cursor: default;
@ -764,31 +703,6 @@ input, button, select.inputform, textarea.inputform, a.btn {
}
}
.btn.pager {
border: 1px solid @lightNavBorder;
font-size: .86em;
padding: .5em 1em;
white-space: nowrap;
font-family: @sansFont;
&:hover {
text-decoration: none;
background: @lightNavBorder;
}
}
.btn.cta.secondary, input[type=submit].secondary {
background: transparent;
color: @primary;
&:hover {
background-color: #f9f9f9;
}
}
.btn.cta.disabled {
background-color: desaturate(@primary, 100%) !important;
border-color: desaturate(@primary, 100%) !important;
}
div.flat-select {
display: inline-block;
position: relative;
@ -851,15 +765,15 @@ input {
margin: 0 auto 3em;
font-size: 1.2em;
&.toosmall {
max-width: 25em;
}
&.tight {
max-width: 30em;
}
&.snug {
max-width: 40em;
}
&.regular {
font-size: 1em;
}
.app {
+ .app {
margin-top: 1.5em;
@ -876,7 +790,7 @@ input {
font-weight: normal;
}
p {
line-height: 1.5;
line-height: 1.4;
}
li {
margin: 0.3em 0;
@ -931,6 +845,20 @@ input {
text-align: center;
}
}
div.features {
margin-top: 1.5em;
text-align: center;
font-size: 0.86em;
ul {
text-align: left;
max-width: 26em;
margin-left: auto !important;
margin-right: auto !important;
li.soon, span.soon {
color: lighten(#111, 40%);
}
}
}
div.blurbs {
>h2 {
text-align: center;
@ -1014,12 +942,7 @@ footer.contain-me {
}
ul {
&.collections {
padding-left: 0;
margin-left: 0;
h3 {
margin-top: 0;
font-weight: normal;
}
li {
&.collection {
a.title {
@ -1061,7 +984,7 @@ footer.contain-me {
}
li {
line-height: 1.5;
line-height: 1.4;
.item-desc, .prog-lang {
font-size: 0.6em;
@ -1093,19 +1016,6 @@ li {
background-color: #dff0d8;
border-color: #d6e9c6;
}
&.danger {
border-color: #856404;
background-color: white;
h3 {
margin: 0 0 0.5em 0;
font-size: 1em;
font-weight: bold;
color: black !important;
}
h3 + p, button {
font-size: 0.86em;
}
}
p {
margin: 0;
@ -1121,7 +1031,7 @@ li {
}
}
ul.errors {
ul.errors {
padding: 0;
text-indent: 0;
li.urgent {
@ -1162,8 +1072,7 @@ body#pad-sub #posts, .atoms {
}
.electron {
font-weight: normal;
font-size: 0.86em;
margin-left: 0.75rem;
margin-left: 0.5em;
}
}
h3, h4 {
@ -1313,7 +1222,7 @@ header {
}
}
&.singleuser {
margin: 0.5em 1em 0.5em 0.25em;
margin: 0.5em 0.25em;
nav#user-nav {
nav > ul > li:first-child {
img {
@ -1321,9 +1230,6 @@ header {
}
}
}
.right-side {
padding-top: 0.5em;
}
}
.dash-nav {
font-weight: bold;
@ -1389,24 +1295,6 @@ form {
font-size: 0.86em;
line-height: 2;
}
&.prominent {
margin: 1em 0;
label {
font-weight: bold;
}
input, select {
width: 100%;
}
select {
font-size: 1em;
padding: 0.5rem;
display: block;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
}
}
div.row {
display: flex;
@ -1416,16 +1304,6 @@ div.row {
}
}
.check, .blip {
font-size: 1.125em;
color: #71D571;
}
.ex.failure {
font-weight: bold;
color: @dangerCol;
}
@media all and (max-width: 450px) {
body#post {
header {
@ -1492,7 +1370,7 @@ div.row {
}
@media all and (max-width: 600px) {
div.row:not(.admin-actions) {
div.row {
flex-direction: column;
}
.half {
@ -1577,11 +1455,6 @@ div.row {
margin-left: 0;
margin-top: 0;
}
article {
.hidden {
.opacity(1);
}
}
}
@media print {
@ -1623,38 +1496,3 @@ div.row {
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;
color: #888;
em, strong {
color: #000;
}
&+h1 {
margin-top: 0.5em;
}
a:link, a:visited, a:hover {
color: @accent;
}
a:first-child {
margin-right: 0.25em;
}
a.coll-name {
font-weight: bold;
margin-left: 0.25em;
}
}

View file

@ -3,6 +3,7 @@
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 */
@ -16,6 +17,7 @@
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 */
@ -29,6 +31,7 @@
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 */
@ -41,6 +44,7 @@
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 */
@ -52,6 +56,7 @@
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 */

View file

@ -2,7 +2,7 @@
# Install Less via npm
if [ ! -e "$(which lessc)" ]; then
sudo npm install -g less@3.5.3
sudo npm install -g less
sudo npm install -g less-plugin-clean-css
else
echo LESS $(npm view less version 2>&1 | grep -v WARN) is installed

View file

@ -1,91 +0,0 @@
/*
* Copyright © 2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
.row.signinbtns {
justify-content: center;
font-size: 1em;
margin-top: 2em;
margin-bottom: 1em;
flex-wrap: wrap;
.loginbtn {
height: 40px;
margin: 0.5em;
&.btn {
box-sizing: border-box;
font-size: 17px;
white-space: nowrap;
img {
height: 1.5em;
vertical-align: middle;
}
}
&#writeas-login, &#slack-login {
img {
margin-top: -0.2em;
}
}
&#gitlab-login {
background-color: #fc6d26;
border-color: #fc6d26;
&:hover {
background-color: darken(#fc6d26, 5%);
border-color: darken(#fc6d26, 5%);
}
}
&#gitea-login {
background-color: #2ecc71;
border-color: #2ecc71;
&:hover {
background-color: #2cc26b;
border-color: #2cc26b;
}
}
&#slack-login, &#gitlab-login, &#gitea-login, &#generic-oauth-login {
font-size: 0.86em;
font-family: @sansFont;
}
&#slack-login, &#generic-oauth-login {
color: @lightTextColor;
background-color: @lightNavBG;
border-color: @lightNavBorder;
&:hover {
background-color: @lightNavHoverBG;
}
}
}
}
.or {
text-align: center;
margin-bottom: 3.5em;
p {
display: inline-block;
background-color: white;
padding: 0 1em;
}
hr {
margin-top: -1.6em;
margin-bottom: 0;
}
hr.short {
max-width: 30rem;
}
}

View file

@ -1,4 +1,4 @@
@actionNavColor: #767676;
@actionNavColor: #999;
body {
margin: 0;
@ -58,7 +58,7 @@ header {
}
p {
&.description {
color: #444;
color: #666;
font-size: 1.1em;
margin-top: 0.5em;
line-height: 1.5;
@ -113,7 +113,7 @@ textarea {
ul {
margin: 0;
padding: 0 0 0 1em;
line-height: 1.5;
line-height: 1.4;
&.collections, &.posts, &.integrations {
list-style: none;
@ -127,6 +127,7 @@ textarea {
&.collection {
a.title {
font-size: 1.3em;
font-weight: bold;
}
}
}
@ -205,7 +206,7 @@ code, textarea#embed {
font-weight: normal;
}
p {
line-height: 1.5;
line-height: 1.4;
}
li {
margin: 0.3em 0;

View file

@ -63,7 +63,7 @@ body#pad, body#pad-sub {
}
}
#belt {
a, button {
a {
color: #000;
}
}
@ -100,7 +100,7 @@ body#pad, body#pad-sub {
}
}
#belt {
a, button {
a {
color: white;
}
}
@ -188,18 +188,18 @@ body#pad, body#pad-sub {
body#pad {
.pad-theme-transition;
textarea, #title {
textarea {
.pad-theme-transition;
}
&.dark {
textarea, #title, #editor {
textarea {
background-color: @darkBG;
color: @darkTextColor;
}
}
&.light {
textarea, #title, #editor {
textarea {
background-color: @lightBG;
color: @lightTextColor;
}

View file

@ -60,7 +60,7 @@
&:hover {
background: @lightNavHoverBG;
}
&:hover > ul, &.open > ul {
&:hover > ul {
display: block;
}
&.selected {
@ -222,13 +222,6 @@ body#pad, body#pad-sub {
font-style: italic;
}
}
button {
font-family: @sansFont;
background-color: transparent;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
border: 0;
}
}
}
}
@ -256,7 +249,7 @@ body#pad {
border: 0;
outline: 0;
}
textarea, #title {
textarea {
position: fixed !important;
top: 3em;
right: 0;
@ -340,15 +333,6 @@ body#pad {
}
}
.body {
line-height: 1.5;
input[type=text].confirm {
width: 100%;
box-sizing: border-box;
}
}
.short {
text-align: center;
}
@ -370,38 +354,12 @@ body#pad {
z-index: 10;
}
body#pad .alert {
position: fixed;
bottom: 0.25em;
left: 2em;
right: 2em;
font-size: 1.1em;
&#edited-elsewhere {
&.hidden {
display: none;
}
a {
font-weight: bold;
}
}
}
@media all and (max-height: 500px) {
body#pad {
textarea {
top: 2.25em;
padding-top: 0.25em;
}
&.classic {
#editor {
top: 5.25em;
}
#title {
top: 3.5rem;
}
}
#tools {
padding-top: 0.5em;
padding-bottom: 0.5em;
@ -455,63 +413,43 @@ body#pad .alert {
}
@media all and (min-width: 50em) {
body#pad, body#pad.classic {
textarea, #title {
body#pad {
textarea {
padding-left: 10%;
padding-right: 10%;
}
.alert {
left: 10%;
right: 10%;
}
}
}
@media all and (min-width: 60em) {
body#pad, body#pad.classic {
textarea, #title {
body#pad {
textarea {
padding-left: 15%;
padding-right: 15%;
}
.alert {
left: 15%;
right: 15%;
}
}
}
@media all and (min-width: 70em) {
body#pad, body#pad.classic {
textarea, #title {
body#pad {
textarea {
padding-left: 20%;
padding-right: 20%;
}
.alert {
left: 20%;
right: 20%;
}
}
}
@media all and (min-width: 85em) {
body#pad, body#pad.classic {
textarea, #title {
body#pad {
textarea {
padding-left: 25%;
padding-right: 25%;
}
.alert {
left: 25%;
right: 25%;
}
}
}
@media all and (min-width: 105em) {
body#pad, body#pad.classic {
textarea, #title {
body#pad {
textarea {
padding-left: 30%;
padding-right: 30%;
}
.alert {
left: 30%;
right: 30%;
}
}
}
@media (pointer: coarse) {

View file

@ -12,50 +12,21 @@ body {
&:hover {
.opacity(1);
}
h1 {
font-size: 1.6em;
}
}
article {
h2#title.dated {
margin-bottom: 0.5em;
}
time.dt-published {
display: block;
color: #666;
margin-bottom: 1em;
}
}
}
}
article, pre, .hljs, #wrapper.archive ul {
article, pre, .hljs {
padding: 0.5em 2rem 1.5em;
}
body#post article, pre, .hljs {
font-size: 1.2em;
}
p.split {
color: #6161FF;
font-style: italic;
font-size: 0.86em;
}
#readmore-sell {
padding: 1em 1em 2em;
background-color: #fafafa;
p.split {
color: black;
font-style: normal;
font-size: 1.4em;
}
.cta + .cta {
margin-left: 0.5em;
}
}
/* Post mixins */
.article-code() {
background-color: #f8f8f8;
@ -68,7 +39,7 @@ p.split {
border-left: 4px solid #ddd;
padding: 0 1em;
margin: 0.5em;
color: #767676;
color: #777;
display: inline-block;
p {
@ -77,7 +48,7 @@ p.split {
}
}
.article-p() {
line-height: 1.5;
line-height: 1.4em;
white-space: pre-wrap; /* CSS 3 */
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
white-space: -pre-wrap; /* Opera 4-6 */

View file

@ -1,490 +0,0 @@
@classicHorizMargin: 2rem;
body#pad.classic {
header {
display: flex;
justify-content: space-between;
align-items: center;
}
#editor {
top: 4em;
bottom: 1em;
}
#title {
top: 4.25rem;
bottom: unset;
height: auto;
font-weight: bold;
font-size: 2em;
padding: 0;
border: 0;
}
#tools {
#belt {
float: none;
}
}
#target {
ul {
a {
padding: 0 0.5em !important;
}
}
}
}
#title {
margin-left: @classicHorizMargin;
margin-right: @classicHorizMargin;
}
.ProseMirror {
position: relative;
height: calc(~"100% - 1.6em");
overflow-y: auto;
box-sizing: border-box;
-moz-box-sizing: border-box;
font-size: 1.2em;
word-wrap: break-word;
white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
padding: 0.5em @classicHorizMargin;
line-height: 1.5;
outline: none;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px;
top: -2px;
bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
.ProseMirror-textblock-dropdown {
min-width: 3em;
}
.ProseMirror-menu {
margin: 0 -4px;
line-height: 1;
}
.ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
.ProseMirror-menuitem {
margin-right: 3px;
display: inline-block;
div {
cursor: pointer;
}
}
.ProseMirror-menuseparator {
border-right: 1px solid #ddd;
margin-right: 3px;
}
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
font-size: 90%;
white-space: nowrap;
}
.ProseMirror-menu-dropdown {
vertical-align: 1px;
cursor: pointer;
position: relative;
padding-right: 15px;
}
.ProseMirror-menu-dropdown-wrap {
padding: 1px 0 1px 4px;
display: inline-block;
position: relative;
}
.ProseMirror-menu-dropdown:after {
content: "";
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 2px);
}
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
position: absolute;
background: white;
color: #666;
border: 1px solid #aaa;
padding: 2px;
}
.ProseMirror-menu-dropdown-menu {
z-index: 15;
min-width: 6em;
}
.ProseMirror-menu-dropdown-item {
cursor: pointer;
padding: 2px 8px 2px 4px;
}
.ProseMirror-menu-dropdown-item:hover {
background: #f2f2f2;
}
.ProseMirror-menu-submenu-wrap {
position: relative;
margin-right: -4px;
}
.ProseMirror-menu-submenu-label:after {
content: "";
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 4px);
}
.ProseMirror-menu-submenu {
display: none;
min-width: 4em;
left: 100%;
top: -3px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-disabled {
opacity: .3;
}
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
display: block;
}
.ProseMirror-menubar {
font-family: @sansFont;
position: relative;
min-height: 1em;
color: #666;
padding: 0.5em;
top: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.8);
z-index: 10;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: visible;
margin-left: @classicHorizMargin;
margin-right: @classicHorizMargin;
}
.ProseMirror-icon {
display: inline-block;
line-height: .8;
vertical-align: -2px; /* Compensate for padding */
padding: 2px 8px;
cursor: pointer;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
cursor: default;
}
.ProseMirror-icon svg {
fill: currentColor;
height: 1em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
/* Add space around the hr to make clicking it easier */
.ProseMirror-example-setup-style hr {
padding: 4px 10px;
border: none;
margin: 1em 0;
background: initial;
}
.ProseMirror-example-setup-style hr:after {
content: "";
display: block;
height: 1px;
background-color: #ccc;
line-height: 2px;
}
.ProseMirror ul, .ProseMirror ol {
padding-left: 30px;
}
.ProseMirror blockquote {
padding-left: 1em;
border-left: 4px solid #ddd;
color: #767676;
margin-left: 0;
margin-right: 0;
}
.ProseMirror-example-setup-style img {
cursor: default;
max-width: 100%;
}
.ProseMirror-prompt {
background: white;
padding: 1em;
border: 1px solid silver;
position: fixed;
border-radius: 0.25em;
z-index: 11;
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
}
.ProseMirror-prompt h5 {
margin: 0 0 0.75em;
font-family: @sansFont;
font-size: 100%;
color: #444;
}
.ProseMirror-prompt input[type="text"],
.ProseMirror-prompt textarea {
background: #eee;
border: none;
outline: none;
}
.ProseMirror-prompt input[type="text"] {
margin: 0.25em 0;
}
.ProseMirror-prompt-close {
position: absolute;
left: 2px;
top: 1px;
color: #666;
border: none;
background: transparent;
padding: 0;
}
.ProseMirror-prompt-close:after {
content: "✕";
font-size: 12px;
}
.ProseMirror-invalid {
background: #ffc;
border: 1px solid #cc7;
border-radius: 4px;
padding: 5px 10px;
position: absolute;
min-width: 10em;
}
.ProseMirror-prompt-buttons {
margin-top: 5px;
display: none;
}
#editor, .editor {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
color: black;
background-clip: padding-box;
padding: 5px 0;
margin: 4em auto 23px auto;
}
.dark #editor {
color: white;
}
.ProseMirror p:first-child,
.ProseMirror h1:first-child,
.ProseMirror h2:first-child,
.ProseMirror h3:first-child,
.ProseMirror h4:first-child,
.ProseMirror h5:first-child,
.ProseMirror h6:first-child {
margin-top: 10px;
}
.ProseMirror p {
margin-bottom: 1em;
}
textarea {
width: 100%;
height: 123px;
border: 1px solid silver;
box-sizing: border-box;
-moz-box-sizing: border-box;
padding: 3px 10px;
border: none;
outline: none;
font-family: inherit;
font-size: inherit;
}
.ProseMirror-menubar-wrapper {
height: 100%;
box-sizing: border-box;
}
.ProseMirror-menubar-wrapper, #markdown textarea {
display: block;
margin-bottom: 4px;
}
.editorreadmore {
color: @textLinkColor;
text-decoration: underline;
text-align: center;
width: 100%;
}
@media all and (min-width: 50em) {
#photo-upload label {
display: inline;
}
.ProseMirror-menubar, #title, #photo-upload {
margin-left: 10%;
margin-right: 10%;
}
.ProseMirror {
padding-left: 10%;
padding-right: 10%;
}
}
@media all and (min-width: 60em) {
.ProseMirror-menubar, #title, #photo-upload {
margin-left: 15%;
margin-right: 15%;
}
.ProseMirror {
padding-left: 15%;
padding-right: 15%;
}
}
@media all and (min-width: 70em) {
.ProseMirror-menubar, #title, #photo-upload {
margin-left: 20%;
margin-right: 20%;
}
.ProseMirror {
padding-left: 20%;
padding-right: 20%;
}
}
@media all and (min-width: 85em) {
.ProseMirror-menubar, #title, #photo-upload {
margin-left: 25%;
margin-right: 25%;
}
.ProseMirror {
padding-left: 25%;
padding-right: 25%;
}
}
@media all and (min-width: 105em) {
.ProseMirror-menubar, #title, #photo-upload {
margin-left: 30%;
margin-right: 30%;
}
.ProseMirror {
padding-left: 30%;
padding-right: 30%;
}
}

View file

@ -1,4 +0,0 @@
@import "prose-editor";
@import "pad-theme";
@import "resources";
@import "lib/elements";

View file

@ -1,13 +0,0 @@
@primary: rgb(114, 120, 191);
@secondary: rgb(114, 191, 133);
@subheaders: #444;
@headerTextColor: black;
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
@dangerCol: #e21d27;
@errUrgentCol: #ecc63c;
@proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238);
@accent: #767676;

View file

@ -1,181 +0,0 @@
/*
* 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
}

View file

@ -1,153 +0,0 @@
package writefreely
import (
"context"
"database/sql"
"encoding/gob"
"errors"
"fmt"
uuid "github.com/nu7hatch/gouuid"
"github.com/stretchr/testify/assert"
"math/rand"
"os"
"strings"
"testing"
"time"
)
var testDB *sql.DB
type ScopedTestBody func(*sql.DB)
// TestMain provides testing infrastructure within this package.
func TestMain(m *testing.M) {
rand.Seed(time.Now().UTC().UnixNano())
gob.Register(&User{})
if runMySQLTests() {
var err error
testDB, err = initMySQL(os.Getenv("WF_USER"), os.Getenv("WF_PASSWORD"), os.Getenv("WF_DB"), os.Getenv("WF_HOST"))
if err != nil {
fmt.Println(err)
return
}
}
code := m.Run()
if runMySQLTests() {
if closeErr := testDB.Close(); closeErr != nil {
fmt.Println(closeErr)
}
}
os.Exit(code)
}
func runMySQLTests() bool {
return len(os.Getenv("TEST_MYSQL")) > 0
}
func initMySQL(dbUser, dbPassword, dbName, dbHost string) (*sql.DB, error) {
if dbUser == "" || dbPassword == "" {
return nil, errors.New("database user or password not set")
}
if dbHost == "" {
dbHost = "localhost"
}
if dbName == "" {
dbName = "writefreely"
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:3306)/%s?charset=utf8mb4&parseTime=true", dbUser, dbPassword, dbHost, dbName)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := ensureMySQL(db); err != nil {
return nil, err
}
return db, nil
}
func ensureMySQL(db *sql.DB) error {
if err := db.Ping(); err != nil {
return err
}
db.SetMaxOpenConns(250)
return nil
}
// withTestDB provides a scoped database connection.
func withTestDB(t *testing.T, testBody ScopedTestBody) {
db, cleanup, err := newTestDatabase(testDB,
os.Getenv("WF_USER"),
os.Getenv("WF_PASSWORD"),
os.Getenv("WF_DB"),
os.Getenv("WF_HOST"),
)
assert.NoError(t, err)
defer func() {
assert.NoError(t, cleanup())
}()
testBody(db)
}
// newTestDatabase creates a new temporary test database. When a test
// database connection is returned, it will have created a new database and
// initialized it with tables from a reference database.
func newTestDatabase(base *sql.DB, dbUser, dbPassword, dbName, dbHost string) (*sql.DB, func() error, error) {
var err error
var baseName = dbName
if baseName == "" {
row := base.QueryRow("SELECT DATABASE()")
err := row.Scan(&baseName)
if err != nil {
return nil, nil, err
}
}
tUUID, _ := uuid.NewV4()
suffix := strings.Replace(tUUID.String(), "-", "_", -1)
newDBName := baseName + suffix
_, err = base.Exec("CREATE DATABASE " + newDBName)
if err != nil {
return nil, nil, err
}
newDB, err := initMySQL(dbUser, dbPassword, newDBName, dbHost)
if err != nil {
return nil, nil, err
}
rows, err := base.Query("SHOW TABLES IN " + baseName)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
return nil, nil, err
}
query := fmt.Sprintf("CREATE TABLE %s LIKE %s.%s", tableName, baseName, tableName)
if _, err := newDB.Exec(query); err != nil {
return nil, nil, err
}
}
cleanup := func() error {
if closeErr := newDB.Close(); closeErr != nil {
fmt.Println(closeErr)
}
_, err = base.Exec("DROP DATABASE " + newDBName)
return err
}
return newDB, cleanup, nil
}
func countRows(t *testing.T, ctx context.Context, db *sql.DB, count int, query string, args ...interface{}) {
var returned int
err := db.QueryRowContext(ctx, query, args...).Scan(&returned)
assert.NoError(t, err, "error executing query %s and args %s", query, args)
assert.Equal(t, count, returned, "unexpected return count %d, expected %d from %s and args %s", returned, count, query, args)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2019 Musing Studio LLC.
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -36,13 +36,6 @@ 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"
}
@ -61,13 +54,6 @@ 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"
@ -79,15 +65,6 @@ 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 ""
@ -101,10 +78,3 @@ func (db *datastore) engine() string {
}
return " ENGINE = InnoDB"
}
func (db *datastore) after(colName string) string {
if db.driverName == driverSQLite {
return ""
}
return " AFTER " + colName
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2019 Musing Studio LLC.
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -13,7 +13,6 @@ package migrations
import (
"database/sql"
"github.com/writeas/web-core/log"
)
@ -56,22 +55,8 @@ func (m *migration) Migrate(db *datastore) error {
}
var migrations = []Migration{
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
New("support oauth", oauth), // V3 -> V4
New("support slack oauth", oauthSlack), // V4 -> v5
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6
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 (v0.13.0)
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
New("support newsletters", supportLetters), // V12 -> V13
New("support password resetting", supportPassReset), // V13 -> V14
New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15
New("support ActivityPub likes", supportRemoteLikes), // V15 -> V16 (v0.16.0)
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
}
// CurrentVer returns the current migration version the application is on
@ -93,9 +78,6 @@ func Migrate(db *datastore) error {
var err error
if db.tableExists("appmigrations") {
err = db.QueryRow("SELECT MAX(version) FROM appmigrations").Scan(&version)
if err != nil {
return err
}
} else {
log.Info("Initializing appmigrations table...")
version = 0

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2019 Musing Studio LLC.
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -12,9 +12,6 @@ package migrations
func supportUserInvites(db *datastore) error {
t, err := db.Begin()
if err != nil {
return err
}
_, err = t.Exec(`CREATE TABLE userinvites (
id ` + db.typeChar(6) + ` NOT NULL ,
owner_id ` + db.typeInt() + ` NOT NULL ,

View file

@ -1,33 +0,0 @@
/*
* Copyright © 2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package migrations
func supportPostSignatures(db *datastore) error {
t, err := db.Begin()
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`ALTER TABLE collections ADD COLUMN post_signature ` + db.typeText() + db.collateMultiByte() + ` NULL` + db.after("script"))
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

View file

@ -1,38 +0,0 @@
/*
* Copyright © 2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package migrations
/**
* Widen `oauth_users.access_token`, necessary only for mysql
*/
func widenOauthAcceesToken(db *datastore) error {
if db.driverName == driverMySQL {
t, err := db.Begin()
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`ALTER TABLE oauth_users MODIFY COLUMN access_token ` + db.typeText() + db.collateMultiByte() + ` NULL`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
}
return nil
}

View file

@ -1,33 +0,0 @@
/*
* Copyright © 2023 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package migrations
func fediverseVerifyProfile(db *datastore) error {
t, err := db.Begin()
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox"))
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

View file

@ -1,58 +0,0 @@
/*
* 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
}

View file

@ -1,37 +0,0 @@
/*
* 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
}

View file

@ -1,33 +0,0 @@
/*
* 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
}

View file

@ -1,38 +0,0 @@
/*
* 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
}

View file

@ -1,5 +1,5 @@
/*
* Copyright © 2019 Musing Studio LLC.
* Copyright © 2019 A Bunch Tell LLC.
*
* This file is part of WriteFreely.
*
@ -12,9 +12,6 @@ package migrations
func supportInstancePages(db *datastore) error {
t, err := db.Begin()
if err != nil {
return err
}
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN title ` + db.typeVarChar(255) + db.collateMultiByte() + ` NULL`)
if err != nil {

View file

@ -1,32 +0,0 @@
/*
* Copyright © 2019 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 supportUserStatus(db *datastore) error {
t, err := db.Begin()
if err != nil {
return err
}
_, err = t.Exec(`ALTER TABLE users ADD COLUMN status ` + db.typeInt() + ` DEFAULT '0' NOT NULL`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

View file

@ -1,54 +0,0 @@
/*
* 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 migrations
import (
"context"
"database/sql"
wf_db "github.com/writefreely/writefreely/db"
)
func oauth(db *datastore) error {
dialect := wf_db.DialectMySQL
if db.driverName == driverSQLite {
dialect = wf_db.DialectSQLite
}
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
createTableUsersOauth, err := dialect.
Table("oauth_users").
SetIfNotExists(false).
Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
ToSQL()
if err != nil {
return err
}
createTableOauthClientState, err := dialect.
Table("oauth_client_states").
SetIfNotExists(false).
Column(dialect.Column("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})).
Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)).
Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefaultCurrentTimestamp()).
UniqueConstraint("state").
ToSQL()
if err != nil {
return err
}
for _, table := range []string{createTableUsersOauth, createTableOauthClientState} {
if _, err := tx.ExecContext(ctx, table); err != nil {
return err
}
}
return nil
})
}

View file

@ -1,88 +0,0 @@
/*
* 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 migrations
import (
"context"
"database/sql"
wf_db "github.com/writefreely/writefreely/db"
)
func oauthSlack(db *datastore) error {
dialect := wf_db.DialectMySQL
if db.driverName == driverSQLite {
dialect = wf_db.DialectSQLite
}
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
builders := []wf_db.SQLBuilder{
dialect.
AlterTable("oauth_client_states").
AddColumn(dialect.
Column(
"provider",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
dialect.
AlterTable("oauth_client_states").
AddColumn(dialect.
Column(
"client_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
dialect.
AlterTable("oauth_users").
AddColumn(dialect.
Column(
"provider",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
dialect.
AlterTable("oauth_users").
AddColumn(dialect.
Column(
"client_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
dialect.
AlterTable("oauth_users").
AddColumn(dialect.
Column(
"access_token",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
}
if dialect != wf_db.DialectSQLite {
// This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases.
builders = append(builders, dialect.
AlterTable("oauth_users").
ChangeColumn("remote_user_id",
dialect.
Column(
"remote_user_id",
wf_db.ColumnTypeVarChar,
wf_db.OptionalInt{Set: true, Value: 128})))
}
for _, builder := range builders {
query, err := builder.ToSQL()
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, query); err != nil {
return err
}
}
return nil
})
}

View file

@ -1,32 +0,0 @@
/*
* Copyright © 2019-2020 Musing Studio LLC.
*
* This file is part of WriteFreely.
*
* WriteFreely is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, included
* in the LICENSE file in this source code package.
*/
package migrations
func supportActivityPubMentions(db *datastore) error {
t, err := db.Begin()
if err != nil {
return err
}
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` NULL`)
if err != nil {
t.Rollback()
return err
}
err = t.Commit()
if err != nil {
t.Rollback()
return err
}
return nil
}

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