Compare commits
No commits in common. "develop" and "T661-disable-accounts" have entirely different histories.
develop
...
T661-disab
220 changed files with 1751 additions and 30829 deletions
|
@ -1 +1,2 @@
|
|||
Dockerfile
|
||||
.git
|
||||
|
|
|
@ -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
2
.github/FUNDING.yml
vendored
|
@ -1,2 +0,0 @@
|
|||
github: writefreely
|
||||
open_collective: writefreely
|
15
.github/dependabot.yml
vendored
15
.github/dependabot.yml
vendored
|
@ -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"
|
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
@ -2,4 +2,4 @@
|
|||
|
||||
---
|
||||
|
||||
- [ ] I have signed the [CLA](https://todo.musing.studio/L1)
|
||||
- [ ] I have signed the [CLA](https://phabricator.write.as/L1)
|
||||
|
|
70
.github/workflows/docker-publish.yml
vendored
70
.github/workflows/docker-publish.yml
vendored
|
@ -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
2
.gitignore
vendored
|
@ -1,9 +1,7 @@
|
|||
node_modules
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
static/local/custom.css
|
||||
build
|
||||
tmp
|
||||
*.ini
|
||||
|
|
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
[submodule "static/js/mathjax"]
|
||||
path = static/js/mathjax
|
||||
url = https://github.com/mathjax/MathJax.git
|
|
@ -1,7 +1,7 @@
|
|||
language: go
|
||||
|
||||
go:
|
||||
- "1.13.x"
|
||||
- "1.11.x"
|
||||
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
|
|
101
CONTRIBUTING.md
101
CONTRIBUTING.md
|
@ -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.
|
||||
|
|
45
Dockerfile
45
Dockerfile
|
@ -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
|
|
@ -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"]
|
104
Makefile
104
Makefile
|
@ -1,5 +1,5 @@
|
|||
GITREV=`git describe | cut -c 2-`
|
||||
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)' -extldflags '-static'"
|
||||
LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'"
|
||||
|
||||
GOCMD=go
|
||||
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
||||
|
@ -14,56 +14,38 @@ 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 .
|
||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
|
||||
build-arm7: 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/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=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
|
||||
build-docker :
|
||||
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
||||
|
@ -71,8 +53,8 @@ build-docker :
|
|||
test:
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
run:
|
||||
$(GOINSTALL) -tags='netgo sqlite' ./...
|
||||
run: dev-assets
|
||||
$(GOINSTALL) -tags='sqlite' ./...
|
||||
$(BINARY_NAME) --debug
|
||||
|
||||
deps :
|
||||
|
@ -87,42 +69,28 @@ install : build
|
|||
cmd/writefreely/$(BINARY_NAME) --init-db
|
||||
cd less/; $(MAKE) install $(MFLAGS)
|
||||
|
||||
release : clean ui
|
||||
release : clean ui assets
|
||||
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)
|
||||
cp -r templates $(BUILDPATH)
|
||||
cp -r pages $(BUILDPATH)
|
||||
cp -r static $(BUILDPATH)
|
||||
mkdir $(BUILDPATH)/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)
|
||||
$(MAKE) build-darwin
|
||||
mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-darwin-arm64
|
||||
mv build/$(BINARY_NAME)-darwin-arm64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_arm64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-windows
|
||||
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
|
||||
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME).exe
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-docker
|
||||
$(MAKE) release-docker
|
||||
|
||||
|
@ -142,18 +110,40 @@ release-docker :
|
|||
|
||||
ui : force_look
|
||||
cd less/; $(MAKE) $(MFLAGS)
|
||||
cd prose/; $(MAKE) $(MFLAGS)
|
||||
|
||||
assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
assets-no-sqlite: generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql
|
||||
|
||||
dev-assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -debug -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
lib-assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -o bindata-lib.go -tags="wflib" schema.sql
|
||||
|
||||
generate :
|
||||
@hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \
|
||||
fi
|
||||
|
||||
$(TMPBIN):
|
||||
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 -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
clean :
|
||||
-rm -rf build
|
||||
-rm -rf tmp
|
||||
cd less/; $(MAKE) clean $(MFLAGS)
|
||||
|
||||
force_look :
|
||||
force_look :
|
||||
true
|
||||
|
|
84
README.md
84
README.md
|
@ -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,73 @@
|
|||
</p>
|
||||
|
||||
|
||||
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.
|
||||
|
||||

|
||||
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 Pro](https://write.as/pro) for individuals, and [Write.as for Teams](https://write.as/for/teams) for businesses. Besides saving you time and effort, both services directly fund WriteFreely development and ensure the long-term sustainability of our open source work.
|
||||
|
||||
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.
|
||||
### [](https://write.as/pro)
|
||||
|
||||
### 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/pro).
|
||||
|
||||
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.
|
||||
### [](https://write.as/for/teams)
|
||||
|
||||
### 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).
|
||||
[Write.as for Teams](https://write.as/for/teams) gives your organization, business, or [open source project](https://write.as/for/open-source) a clutter-free space to share updates or proposals and build your collective knowledge. We take care of hosting, upgrades, backups, and maintenance so your team 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
|
||||
To get started, head over to our [Getting Started guide](https://writefreely.org/start). For production use, jump to the [Running in Production](https://writefreely.org/start#production) section.
|
||||
|
||||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||
## 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) —️ and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
|
||||
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs). 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/setup).
|
||||
|
||||
## 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.
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, send an email to security@writefreely.org.
|
582
account.go
582
account.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,17 +13,13 @@ package writefreely
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/writefreely/writefreely/mailer"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/guregu/null/zero"
|
||||
|
@ -31,9 +27,9 @@ import (
|
|||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/data"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -52,7 +48,6 @@ type (
|
|||
Separator template.HTML
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
CollAlias string
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -75,7 +70,7 @@ func canUserInvite(cfg *config.Config, isAdmin bool) bool {
|
|||
}
|
||||
|
||||
func (up *UserPage) SetMessaging(u *User) {
|
||||
// up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
||||
//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
||||
}
|
||||
|
||||
const (
|
||||
|
@ -90,11 +85,6 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
|
||||
func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqJSON := IsJSON(r)
|
||||
|
||||
// Get params
|
||||
|
@ -154,6 +144,8 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
}
|
||||
|
||||
// Handle empty optional params
|
||||
// TODO: remove this var
|
||||
createdWithPass := true
|
||||
hashedPass, err := auth.HashPass([]byte(signup.Pass))
|
||||
if err != nil {
|
||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||
|
@ -163,19 +155,31 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
u := &User{
|
||||
Username: signup.Alias,
|
||||
HashedPass: hashedPass,
|
||||
HasPass: true,
|
||||
Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
|
||||
HasPass: createdWithPass,
|
||||
Email: zero.NewString("", signup.Email != ""),
|
||||
Created: time.Now().Truncate(time.Second).UTC(),
|
||||
}
|
||||
if signup.Email != "" {
|
||||
encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email)
|
||||
if err != nil {
|
||||
log.Error("Unable to encrypt email: %s\n", err)
|
||||
} else {
|
||||
u.Email.String = string(encEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// Create actual user
|
||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil {
|
||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log invite if needed
|
||||
if signup.InviteCode != "" {
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
|
||||
cu, err := app.db.GetUserForAuth(signup.Alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -189,35 +193,20 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
resUser := &AuthUser{
|
||||
User: u,
|
||||
}
|
||||
if !createdWithPass {
|
||||
resUser.Password = signup.Pass
|
||||
}
|
||||
title := signup.Alias
|
||||
if signup.Normalize {
|
||||
title = desiredUsername
|
||||
}
|
||||
resUser.Collections = &[]Collection{
|
||||
{
|
||||
Alias: signup.Alias,
|
||||
Title: title,
|
||||
Description: signup.Description,
|
||||
Alias: signup.Alias,
|
||||
Title: title,
|
||||
},
|
||||
}
|
||||
|
||||
var coll *Collection
|
||||
if signup.Monetization != "" {
|
||||
if coll == nil {
|
||||
coll, err = app.db.GetCollection(signup.Alias)
|
||||
if err != nil {
|
||||
log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization)
|
||||
if err != nil {
|
||||
log.Error("Unable to add monetization on signup: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
coll.Monetization = signup.Monetization
|
||||
}
|
||||
|
||||
var token string
|
||||
if reqJSON && !signup.Web {
|
||||
token, err = app.db.GetAccessToken(u.ID)
|
||||
|
@ -321,20 +310,16 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := &struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
EmailEnabled bool
|
||||
LoginUsername string
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
To: r.FormValue("to"),
|
||||
Message: template.HTML(""),
|
||||
Flashes: []template.HTML{},
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||
pageForReq(app, r),
|
||||
r.FormValue("to"),
|
||||
template.HTML(""),
|
||||
[]template.HTML{},
|
||||
getTempInfo(app, "login-user", r, w),
|
||||
}
|
||||
|
||||
if earlyError != "" {
|
||||
|
@ -409,11 +394,6 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
var err error
|
||||
var signin userCredentials
|
||||
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return err
|
||||
}
|
||||
|
||||
// Log in with one-time token if one is given
|
||||
if oneTimeToken != "" {
|
||||
log.Info("Login: Logging user in via token.")
|
||||
|
@ -508,13 +488,10 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
// User has no email set, so check if they haven't added a password, either,
|
||||
// so we can return a more helpful error message.
|
||||
if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
|
||||
log.Info("Tried logging into %s, but no password or email.", signin.Alias)
|
||||
log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
|
||||
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
||||
}
|
||||
}
|
||||
if len(u.HashedPass) == 0 {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
|
||||
}
|
||||
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
|
||||
}
|
||||
|
@ -581,7 +558,7 @@ func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser
|
|||
}
|
||||
passIsSet, err := app.db.IsUserPassSet(u.ID)
|
||||
if err != nil {
|
||||
// TODO: correct error message
|
||||
// TODO: correct error meesage
|
||||
log.Error("Login: Unable to get user collections: %v", err)
|
||||
}
|
||||
|
||||
|
@ -714,22 +691,6 @@ func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) e
|
|||
return ErrBadRequestedType
|
||||
}
|
||||
|
||||
isAnonPosts := r.FormValue("anonymous") == "1"
|
||||
if isAnonPosts {
|
||||
pageStr := r.FormValue("page")
|
||||
pg, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
log.Error("Error parsing page parameter '%s': %s", pageStr, err)
|
||||
pg = 1
|
||||
}
|
||||
|
||||
p, err := app.db.GetAnonymousPosts(u, pg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return impart.WriteSuccess(w, p, http.StatusOK)
|
||||
}
|
||||
|
||||
var err error
|
||||
p := GetPostsCache(u.ID)
|
||||
if p == nil {
|
||||
|
@ -770,7 +731,7 @@ func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := app.db.GetAnonymousPosts(u, 1)
|
||||
p, err := app.db.GetAnonymousPosts(u)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch anon posts: %v", err)
|
||||
}
|
||||
|
@ -789,23 +750,20 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
log.Error("unable to fetch collections: %v", err)
|
||||
}
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view articles: %v", err)
|
||||
}
|
||||
d := struct {
|
||||
*UserPage
|
||||
AnonymousPosts *[]PublicPost
|
||||
Collections *[]Collection
|
||||
Silenced bool
|
||||
Suspended bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
|
||||
AnonymousPosts: p,
|
||||
Collections: c,
|
||||
Silenced: silenced,
|
||||
Suspended: suspended,
|
||||
}
|
||||
d.UserPage.SetMessaging(u)
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
|
@ -827,12 +785,9 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
|
|||
uc, _ := app.db.GetUserCollectionCount(u.ID)
|
||||
// TODO: handle any errors
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view collections: %v", err)
|
||||
log.Error("view collections %v", err)
|
||||
return fmt.Errorf("view collections: %v", err)
|
||||
}
|
||||
d := struct {
|
||||
|
@ -842,13 +797,13 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
|
|||
UsedCollections, TotalCollections int
|
||||
|
||||
NewBlogsDisabled bool
|
||||
Silenced bool
|
||||
Suspended bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
|
||||
Collections: c,
|
||||
UsedCollections: int(uc),
|
||||
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
|
||||
Silenced: silenced,
|
||||
Suspended: suspended,
|
||||
}
|
||||
d.UserPage.SetMessaging(u)
|
||||
showUserPage(w, "collections", d)
|
||||
|
@ -866,11 +821,8 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
return ErrCollectionNotFound
|
||||
}
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view edit collection %v", err)
|
||||
return fmt.Errorf("view edit collection: %v", err)
|
||||
}
|
||||
|
@ -878,19 +830,11 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
obj := struct {
|
||||
*UserPage
|
||||
*Collection
|
||||
Silenced bool
|
||||
|
||||
config.EmailCfg
|
||||
LetterReplyTo string
|
||||
Suspended bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||
Collection: c,
|
||||
Silenced: silenced,
|
||||
EmailCfg: app.cfg.Email,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if obj.EmailCfg.Enabled() {
|
||||
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
|
||||
Suspended: suspended,
|
||||
}
|
||||
|
||||
showUserPage(w, "collection", obj)
|
||||
|
@ -1038,10 +982,9 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
if c.OwnerID != u.ID {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
}
|
||||
|
||||
topPosts, err := app.db.GetTopPosts(u, alias, c.hostName)
|
||||
topPosts, err := app.db.GetTopPosts(u, alias)
|
||||
if err != nil {
|
||||
log.Error("Unable to get top posts: %v", err)
|
||||
return err
|
||||
|
@ -1053,32 +996,25 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
titleStats = c.DisplayTitle() + " "
|
||||
}
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view stats: %v", err)
|
||||
return err
|
||||
}
|
||||
obj := struct {
|
||||
*UserPage
|
||||
VisitsBlog string
|
||||
Collection *Collection
|
||||
TopPosts *[]PublicPost
|
||||
APFollowers int
|
||||
EmailEnabled bool
|
||||
EmailSubscribers int
|
||||
Silenced bool
|
||||
VisitsBlog string
|
||||
Collection *Collection
|
||||
TopPosts *[]PublicPost
|
||||
APFollowers int
|
||||
Suspended bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||
VisitsBlog: alias,
|
||||
Collection: c,
|
||||
TopPosts: topPosts,
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
Silenced: silenced,
|
||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||
VisitsBlog: alias,
|
||||
Collection: c,
|
||||
TopPosts: topPosts,
|
||||
Suspended: suspended,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if app.cfg.App.Federation {
|
||||
folls, err := app.db.GetAPFollowers(c)
|
||||
if err != nil {
|
||||
|
@ -1086,79 +1022,14 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
obj.APFollowers = len(*folls)
|
||||
}
|
||||
if obj.EmailEnabled {
|
||||
subs, err := app.db.GetEmailSubscribers(c.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj.EmailSubscribers = len(subs)
|
||||
}
|
||||
|
||||
showUserPage(w, "stats", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewSubscribers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
c, err := app.db.GetCollection(vars["collection"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := r.FormValue("filter")
|
||||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Collection CollectionNav
|
||||
EmailSubs []*EmailSubscriber
|
||||
Followers *[]RemoteUser
|
||||
Silenced bool
|
||||
|
||||
Filter string
|
||||
FederationEnabled bool
|
||||
CanEmailSub bool
|
||||
CanAddSubs bool
|
||||
EmailSubsEnabled bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, c.DisplayTitle()+" Subscribers", flashes),
|
||||
Collection: CollectionNav{
|
||||
Collection: c,
|
||||
Path: r.URL.Path,
|
||||
SingleUser: app.cfg.App.SingleUser,
|
||||
},
|
||||
Silenced: u.IsSilenced(),
|
||||
Filter: filter,
|
||||
FederationEnabled: app.cfg.App.Federation,
|
||||
CanEmailSub: app.cfg.Email.Enabled(),
|
||||
EmailSubsEnabled: c.EmailSubsEnabled(),
|
||||
}
|
||||
|
||||
obj.Followers, err = app.db.GetAPFollowers(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obj.EmailSubs, err = app.db.GetEmailSubscribers(c.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if obj.Filter == "" {
|
||||
// Set permission to add email subscribers
|
||||
//obj.CanAddSubs = app.db.GetUserAttribute(c.OwnerID, userAttrCanAddEmailSubs) == "1"
|
||||
}
|
||||
|
||||
showUserPage(w, "subscribers", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
fullUser, err := app.db.GetUserByID(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("Unable to get user for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
|
@ -1171,290 +1042,24 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
||||
enableOauthSlack := app.Config().SlackOauth.ClientID != ""
|
||||
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
|
||||
enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
|
||||
enableOauthGeneric := app.Config().GenericOauth.ClientID != ""
|
||||
enableOauthGitea := app.Config().GiteaOauth.ClientID != ""
|
||||
|
||||
oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get oauth accounts for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
for idx, oauthAccount := range oauthAccounts {
|
||||
switch oauthAccount.Provider {
|
||||
case "slack":
|
||||
enableOauthSlack = false
|
||||
case "write.as":
|
||||
enableOauthWriteAs = false
|
||||
case "gitlab":
|
||||
enableOauthGitLab = false
|
||||
case "generic":
|
||||
oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName
|
||||
oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect
|
||||
enableOauthGeneric = false
|
||||
case "gitea":
|
||||
enableOauthGitea = false
|
||||
}
|
||||
}
|
||||
|
||||
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0
|
||||
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Silenced bool
|
||||
CSRFField template.HTML
|
||||
OauthSection bool
|
||||
OauthAccounts []oauthAccountInfo
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
OauthGitLab bool
|
||||
GitLabDisplayName string
|
||||
OauthGeneric bool
|
||||
OauthGenericDisplayName string
|
||||
OauthGitea bool
|
||||
GiteaDisplayName string
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Suspended bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Silenced: fullUser.IsSilenced(),
|
||||
CSRFField: csrf.TemplateField(r),
|
||||
OauthSection: displayOauthSection,
|
||||
OauthAccounts: oauthAccounts,
|
||||
OauthSlack: enableOauthSlack,
|
||||
OauthWriteAs: enableOauthWriteAs,
|
||||
OauthGitLab: enableOauthGitLab,
|
||||
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
OauthGeneric: enableOauthGeneric,
|
||||
OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName),
|
||||
OauthGitea: enableOauthGitea,
|
||||
GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName),
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Suspended: fullUser.IsSilenced(),
|
||||
}
|
||||
|
||||
showUserPage(w, "settings", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
token := r.FormValue("t")
|
||||
resetting := false
|
||||
var userID int64 = 0
|
||||
if token != "" {
|
||||
// Show new password page
|
||||
userID = app.db.GetUserFromPasswordReset(token)
|
||||
if userID == 0 {
|
||||
return impart.HTTPError{http.StatusNotFound, ""}
|
||||
}
|
||||
resetting = true
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
newPass := r.FormValue("new-pass")
|
||||
if newPass == "" {
|
||||
// Send password reset email
|
||||
return handleResetPasswordInit(app, w, r)
|
||||
}
|
||||
|
||||
// Do actual password reset
|
||||
// Assumes token has been validated above
|
||||
err := doAutomatedPasswordChange(app, userID, newPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = app.db.ConsumePasswordResetToken(token)
|
||||
if err != nil {
|
||||
log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err)
|
||||
}
|
||||
addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil)
|
||||
return impart.HTTPError{http.StatusFound, "/login"}
|
||||
}
|
||||
|
||||
f, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
||||
// Show reset password page
|
||||
d := struct {
|
||||
page.StaticPage
|
||||
Flashes []string
|
||||
EmailEnabled bool
|
||||
CSRFField template.HTML
|
||||
Token string
|
||||
IsResetting bool
|
||||
IsSent bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Flashes: f,
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
CSRFField: csrf.TemplateField(r),
|
||||
Token: token,
|
||||
IsResetting: resetting,
|
||||
IsSent: r.FormValue("sent") == "1",
|
||||
}
|
||||
err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d)
|
||||
if err != nil {
|
||||
log.Error("Unable to render password reset page: %v", err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func doAutomatedPasswordChange(app *App, userID int64, newPass string) error {
|
||||
// Do password reset
|
||||
hashedPass, err := auth.HashPass([]byte(newPass))
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||
}
|
||||
|
||||
// Do update
|
||||
err = app.db.ChangePassphrase(userID, true, "", hashedPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
returnLoc := impart.HTTPError{http.StatusFound, "/reset"}
|
||||
|
||||
if !app.cfg.Email.Enabled() {
|
||||
// Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
ip := spam.GetIP(r)
|
||||
alias := r.FormValue("alias")
|
||||
|
||||
u, err := app.db.GetUserForAuth(alias)
|
||||
if err != nil {
|
||||
if strings.IndexAny(alias, "@") > 0 {
|
||||
addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
addSessionFlash(app, w, r, ErrUserNotFound.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
if u.IsAdmin() {
|
||||
// Prevent any reset emails on admin accounts
|
||||
log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip)
|
||||
return returnLoc
|
||||
}
|
||||
if u.Email.String == "" {
|
||||
err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."}
|
||||
addSessionFlash(app, w, r, err.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet {
|
||||
err = loginViaEmail(app, u.Username, "/me/settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
token, err := app.db.CreatePasswordResetToken(u.ID)
|
||||
if err != nil {
|
||||
log.Error("Error resetting password: %s", err)
|
||||
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
err = emailPasswordReset(app, u.EmailClear(app.keys), token)
|
||||
if err != nil {
|
||||
log.Error("Error emailing password reset: %s", err)
|
||||
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil)
|
||||
returnLoc.Message += "?sent=1"
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
func emailPasswordReset(app *App, toEmail, token string) error {
|
||||
// Send email
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)
|
||||
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Password Reset")
|
||||
m.SetHTML(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
<p>We received a request to reset your password on %s. Please click the following link to continue:</p>
|
||||
<p style="font-size:1.2em;margin-bottom:1.5em;"><a href="%s/reset?t=%s">Reset your password</a></p>
|
||||
<p style="font-size: 0.86em;margin:1em auto">%s</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara))
|
||||
return mlr.Send(m)
|
||||
}
|
||||
|
||||
func loginViaEmail(app *App, alias, redirectTo string) error {
|
||||
if !app.cfg.Email.Enabled() {
|
||||
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server")
|
||||
}
|
||||
|
||||
// Make sure user has added an email
|
||||
// TODO: create a new func to just get user's email; "ForAuth" doesn't match here
|
||||
u, _ := app.db.GetUserForAuth(alias)
|
||||
if u == nil {
|
||||
if strings.IndexAny(alias, "@") > 0 {
|
||||
return ErrUserNotFoundEmail
|
||||
}
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if u.Email.String == "" {
|
||||
return impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Log in with password, instead."}
|
||||
}
|
||||
|
||||
// Generate one-time login token
|
||||
t, err := app.db.GetTemporaryOneTimeAccessToken(u.ID, 60*15, true)
|
||||
if err != nil {
|
||||
log.Error("Unable to generate token for email login: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to generate token."}
|
||||
}
|
||||
|
||||
// Send email
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toEmail := u.EmailClear(app.keys)
|
||||
footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara)
|
||||
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Email Login")
|
||||
|
||||
m.SetHTML(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
<p style="font-size:1.2em;margin-bottom:1.5em;text-align:center"><a href="%s/login?to=%s&with=%s">Log in to %s here</a>.</p>
|
||||
<p style="font-size: 0.86em;color:#666;text-align:center;max-width:35em;margin:1em auto">%s</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara))
|
||||
return mlr.Send(m)
|
||||
}
|
||||
|
||||
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
|
||||
session, err := app.sessionStore.Get(r, "t")
|
||||
if err != nil {
|
||||
|
@ -1492,56 +1097,3 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
|
|||
// Return value
|
||||
return s
|
||||
}
|
||||
|
||||
func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
if !app.cfg.App.OpenDeletion {
|
||||
return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."}
|
||||
}
|
||||
|
||||
confirmUsername := r.PostFormValue("confirm-username")
|
||||
if u.Username != confirmUsername {
|
||||
return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."}
|
||||
}
|
||||
|
||||
// Check for account deletion safeguards in place
|
||||
if u.IsAdmin() {
|
||||
return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."}
|
||||
}
|
||||
|
||||
err := app.db.DeleteAccount(u.ID)
|
||||
if err != nil {
|
||||
log.Error("user delete account: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)}
|
||||
}
|
||||
|
||||
// FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset
|
||||
_ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil)
|
||||
return impart.HTTPError{http.StatusFound, "/me/logout"}
|
||||
}
|
||||
|
||||
func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
provider := r.FormValue("provider")
|
||||
clientID := r.FormValue("client_id")
|
||||
remoteUserID := r.FormValue("remote_user_id")
|
||||
|
||||
err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID)
|
||||
if err != nil {
|
||||
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
||||
}
|
||||
|
||||
return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"}
|
||||
}
|
||||
|
||||
func prepareUserEmail(input string, emailKey []byte) zero.String {
|
||||
email := zero.NewString("", input != "")
|
||||
if len(input) > 0 {
|
||||
encEmail, err := data.Encrypt(emailKey, input)
|
||||
if err != nil {
|
||||
log.Error("Unable to encrypt email: %s\n", err)
|
||||
} else {
|
||||
email.String = string(encEmail)
|
||||
|
||||
}
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
|
|
@ -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"}
|
||||
}
|
523
activitypub.go
523
activitypub.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -17,74 +17,33 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/activity/streams"
|
||||
"github.com/writeas/activityserve"
|
||||
"github.com/writeas/httpsig"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/activitypub"
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/silobridge"
|
||||
)
|
||||
|
||||
const (
|
||||
// TODO: delete. don't use this!
|
||||
apCustomHandleDefault = "blog"
|
||||
|
||||
apCacheTime = time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
apCollectionPostIRIRegex = regexp.MustCompile("/api/collections/([a-z0-9\\-]+)/posts/([a-z0-9\\-]+)$")
|
||||
apDraftPostIRIRegex = regexp.MustCompile("/api/posts/([a-z0-9\\-]+)$")
|
||||
)
|
||||
|
||||
var instanceColl *Collection
|
||||
|
||||
func initActivityPub(app *App) {
|
||||
ur, _ := url.Parse(app.cfg.App.Host)
|
||||
instanceColl = &Collection{
|
||||
ID: 0,
|
||||
Alias: ur.Host,
|
||||
Title: ur.Host,
|
||||
db: app.db,
|
||||
hostName: app.cfg.App.Host,
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteUser struct {
|
||||
ID int64
|
||||
ActorID string
|
||||
Inbox string
|
||||
SharedInbox string
|
||||
URL string
|
||||
Handle string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) CreatedFriendly() string {
|
||||
return ru.Created.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) EstimatedHandle() string {
|
||||
if ru.Handle != "" {
|
||||
return ru.Handle
|
||||
}
|
||||
username := filepath.Base(ru.ActorID)
|
||||
host, _ := url.Parse(ru.ActorID)
|
||||
return username + "@" + host.Host
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
||||
|
@ -103,28 +62,17 @@ func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
|||
}
|
||||
}
|
||||
|
||||
func activityPubClient() *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
alias := vars["alias"]
|
||||
if alias == "" {
|
||||
alias = filepath.Base(r.RequestURI)
|
||||
}
|
||||
|
||||
// TODO: enforce visibility
|
||||
// Get base Collection data
|
||||
var c *Collection
|
||||
var err error
|
||||
if alias == r.Host {
|
||||
c = instanceColl
|
||||
} else if app.cfg.App.SingleUser {
|
||||
if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
|
@ -132,22 +80,18 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
if !c.IsInstanceColl() {
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection activities: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection activities: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
p := c.PersonObject()
|
||||
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, p, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -169,12 +113,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection outbox: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
if suspended {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
@ -201,16 +145,14 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
|||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
|
||||
ocp.OrderedItems = []interface{}{}
|
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false, "")
|
||||
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
|
||||
for _, pp := range *posts {
|
||||
pp.Collection = res
|
||||
o := pp.ActivityObject(app)
|
||||
o := pp.ActivityObject(app.cfg)
|
||||
a := activitystreams.NewCreateActivity(o)
|
||||
a.Context = nil
|
||||
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
||||
}
|
||||
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -232,12 +174,12 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection followers: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
if suspended {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
@ -265,7 +207,6 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
|
|||
ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID)
|
||||
}
|
||||
*/
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -287,12 +228,12 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection following: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
if suspended {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
@ -310,7 +251,6 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
|
|||
// Return outbox page
|
||||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p)
|
||||
ocp.OrderedItems = []interface{}{}
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -330,12 +270,12 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
// TODO: return Reject?
|
||||
return err
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection inbox: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
if suspended {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
@ -357,60 +297,11 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
a := streams.NewAccept()
|
||||
p := c.PersonObject()
|
||||
var to *url.URL
|
||||
var isFollow, isUnfollow, isLike, isUnlike bool
|
||||
var likePostID, unlikePostID string
|
||||
var isFollow, isUnfollow bool
|
||||
fullActor := &activitystreams.Person{}
|
||||
var remoteUser *RemoteUser
|
||||
|
||||
res := &streams.Resolver{
|
||||
LikeCallback: func(l *streams.Like) error {
|
||||
isLike = true
|
||||
|
||||
// 1) Use the Like concrete type here
|
||||
// 2) Errors are propagated to res.Deserialize call below
|
||||
m["@context"] = []string{activitystreams.Namespace}
|
||||
b, _ := json.Marshal(m)
|
||||
if debugging {
|
||||
log.Info("Like: %s", b)
|
||||
}
|
||||
|
||||
_, likeID := l.GetId()
|
||||
if likeID == nil {
|
||||
log.Error("Didn't resolve Like ID")
|
||||
}
|
||||
if p := l.HasObject(0); p == streams.NoPresence {
|
||||
return fmt.Errorf("no object for Like activity at index 0")
|
||||
}
|
||||
|
||||
obj := l.Raw().GetObjectIRI(0)
|
||||
/*
|
||||
// TODO: handle this more robustly
|
||||
l.ResolveObject(&streams.Resolver{
|
||||
LinkCallback: func(link *streams.Link) error {
|
||||
return nil
|
||||
},
|
||||
}, 0)
|
||||
*/
|
||||
|
||||
if obj == nil {
|
||||
return fmt.Errorf("didn't get ObjectIRI to Like")
|
||||
}
|
||||
likePostID, err = parsePostIDFromURL(app, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Finally, get actor information
|
||||
_, from := l.GetActor(0)
|
||||
if from == nil {
|
||||
return fmt.Errorf("No valid actor string")
|
||||
}
|
||||
fullActor, remoteUser, err = getActor(app, from.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
FollowCallback: func(f *streams.Follow) error {
|
||||
isFollow = true
|
||||
|
||||
|
@ -426,7 +317,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
if followID == nil {
|
||||
log.Error("Didn't resolve follow ID")
|
||||
} else {
|
||||
aID := c.FederatedAccount() + "#accept-" + id.GenerateFriendlyRandomString(20)
|
||||
aID := c.FederatedAccount() + "#accept-" + store.GenerateFriendlyRandomString(20)
|
||||
acceptID, err := url.Parse(aID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't parse generated Accept URL '%s': %v", aID, err)
|
||||
|
@ -436,17 +327,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
a.AppendObject(f.Raw())
|
||||
_, to = f.GetActor(0)
|
||||
obj := f.Raw().GetObjectIRI(0)
|
||||
if obj == nil {
|
||||
if debugging {
|
||||
log.Error("GetObjectIRI on Follow for actor is empty; trying object")
|
||||
}
|
||||
ao := f.Raw().GetObject(0)
|
||||
if ao == nil {
|
||||
log.Error("Fell back to GetObject and none parsed, so no actor ID! Follow request probably FAILED!")
|
||||
} else {
|
||||
obj = ao.GetId()
|
||||
}
|
||||
}
|
||||
a.AppendActor(obj)
|
||||
|
||||
// First get actor information
|
||||
|
@ -460,6 +340,8 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
return impart.RenderActivityJSON(w, m, http.StatusOK)
|
||||
},
|
||||
UndoCallback: func(u *streams.Undo) error {
|
||||
isUnfollow = true
|
||||
|
||||
m["@context"] = []string{activitystreams.Namespace}
|
||||
b, _ := json.Marshal(m)
|
||||
if debugging {
|
||||
|
@ -467,37 +349,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
a.AppendObject(u.Raw())
|
||||
|
||||
// Check type -- we handle Undo:Like and Undo:Follow
|
||||
_, err := u.ResolveObject(&streams.Resolver{
|
||||
LikeCallback: func(like *streams.Like) error {
|
||||
isUnlike = true
|
||||
|
||||
_, from := like.GetActor(0)
|
||||
obj := like.Raw().GetObjectIRI(0)
|
||||
if obj == nil {
|
||||
return fmt.Errorf("didn't get ObjectIRI for Undo Like")
|
||||
}
|
||||
unlikePostID, err = parsePostIDFromURL(app, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullActor, remoteUser, err = getActor(app, from.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// TODO: add FollowCallback for more robust handling
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isUnlike {
|
||||
return nil
|
||||
}
|
||||
|
||||
isUnfollow = true
|
||||
_, to = u.GetActor(0)
|
||||
// TODO: get actor from object.object, not object
|
||||
obj := u.Raw().GetObjectIRI(0)
|
||||
|
@ -530,89 +381,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
return err
|
||||
}
|
||||
|
||||
// Handle synchronous activities
|
||||
if isLike {
|
||||
t, err := app.db.Begin()
|
||||
if err != nil {
|
||||
log.Error("Unable to start transaction: %v", err)
|
||||
return fmt.Errorf("unable to start transaction: %v", err)
|
||||
}
|
||||
|
||||
var remoteUserID int64
|
||||
if remoteUser != nil {
|
||||
remoteUserID = remoteUser.ID
|
||||
} else {
|
||||
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
|
||||
}
|
||||
|
||||
// Add like
|
||||
_, err = t.Exec("INSERT INTO remote_likes (post_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", likePostID, remoteUserID)
|
||||
if err != nil {
|
||||
if !app.db.isDuplicateKeyErr(err) {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add like in DB: %v\n", err)
|
||||
return fmt.Errorf("Couldn't add like in DB: %v", err)
|
||||
} else {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add like in DB: %v\n", err)
|
||||
return fmt.Errorf("Couldn't add like in DB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
log.Error("Rolling back after Commit(): %v\n", err)
|
||||
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
|
||||
}
|
||||
|
||||
if debugging {
|
||||
log.Info("Successfully liked post %s by remote user %s", likePostID, remoteUser.URL)
|
||||
}
|
||||
return impart.RenderActivityJSON(w, "", http.StatusOK)
|
||||
} else if isUnlike {
|
||||
t, err := app.db.Begin()
|
||||
if err != nil {
|
||||
log.Error("Unable to start transaction: %v", err)
|
||||
return fmt.Errorf("unable to start transaction: %v", err)
|
||||
}
|
||||
|
||||
var remoteUserID int64
|
||||
if remoteUser != nil {
|
||||
remoteUserID = remoteUser.ID
|
||||
} else {
|
||||
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
|
||||
}
|
||||
|
||||
// Remove like
|
||||
_, err = t.Exec("DELETE FROM remote_likes WHERE post_id = ? AND remote_user_id = ?", unlikePostID, remoteUserID)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't delete Like from DB: %v\n", err)
|
||||
return fmt.Errorf("Couldn't delete Like from DB: %v", err)
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
log.Error("Rolling back after Commit(): %v\n", err)
|
||||
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
|
||||
}
|
||||
|
||||
if debugging {
|
||||
log.Info("Successfully un-liked post %s by remote user %s", unlikePostID, remoteUser.URL)
|
||||
}
|
||||
return impart.RenderActivityJSON(w, "", http.StatusOK)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if to == nil {
|
||||
if debugging {
|
||||
log.Error("No `to` value!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
am, err := a.Serialize()
|
||||
if err != nil {
|
||||
|
@ -621,6 +390,10 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
am["@context"] = []string{activitystreams.Namespace}
|
||||
|
||||
if to == nil {
|
||||
log.Error("No to! %v", err)
|
||||
return
|
||||
}
|
||||
err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am)
|
||||
if err != nil {
|
||||
log.Error("Unable to make activity POST: %v", err)
|
||||
|
@ -639,9 +412,8 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
if remoteUser != nil {
|
||||
followerID = remoteUser.ID
|
||||
} else {
|
||||
// TODO: use apAddRemoteUser() here, instead!
|
||||
// Add follower locally, since it wasn't found before
|
||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
|
||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox)
|
||||
if err != nil {
|
||||
// if duplicate key, res will be nil and panic on
|
||||
// res.LastInsertId below
|
||||
|
@ -705,7 +477,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
|
||||
r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
|
||||
r.Header.Add("Content-Type", "application/activity+json")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
|
@ -730,7 +502,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
}
|
||||
}
|
||||
|
||||
resp, err := activityPubClient().Do(r)
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -738,7 +510,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -755,23 +527,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
|
||||
r, _ := http.NewRequest("GET", url, nil)
|
||||
r.Header.Add("Accept", "application/activity+json")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
|
||||
p := instanceColl.PersonObject()
|
||||
h := sha256.New()
|
||||
h.Write([]byte{})
|
||||
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
|
||||
// Sign using the 'Signature' header
|
||||
privKey, err := activitypub.DecodePrivateKey(p.GetPrivKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer := httpsig.NewSigner(p.PublicKey.ID, privKey, httpsig.RSASHA256, []string{"(request-target)", "date", "host", "digest"})
|
||||
err = signer.SignSigHeader(r)
|
||||
if err != nil {
|
||||
log.Error("Can't sign: %v", err)
|
||||
}
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
|
||||
if debugging {
|
||||
dump, err := httputil.DumpRequestOut(r, true)
|
||||
|
@ -782,7 +538,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
}
|
||||
}
|
||||
|
||||
resp, err := activityPubClient().Do(r)
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -790,7 +546,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -808,7 +564,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
}
|
||||
p.Collection.hostName = app.cfg.App.Host
|
||||
actor := p.Collection.PersonObject(collID)
|
||||
na := p.ActivityObject(app)
|
||||
na := p.ActivityObject(app.cfg)
|
||||
|
||||
// Add followers
|
||||
p.Collection.ID = collID
|
||||
|
@ -833,13 +589,11 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
|
||||
for si, instFolls := range inboxes {
|
||||
na.CC = []string{}
|
||||
na.CC = append(na.CC, instFolls...)
|
||||
da := activitystreams.NewDeleteActivity(na)
|
||||
// Make the ID unique to ensure it works in Pleroma
|
||||
// See: https://git.pleroma.social/pleroma/pleroma/issues/1481
|
||||
da.ID += "#Delete"
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, si, da)
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, si, activitystreams.NewDeleteActivity(na))
|
||||
if err != nil {
|
||||
log.Error("Couldn't delete post! %v", err)
|
||||
}
|
||||
|
@ -848,16 +602,6 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
}
|
||||
|
||||
func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||
// If app is private, do not federate
|
||||
if app.cfg.App.Private {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not federate posts from private or protected blogs
|
||||
if p.Collection.Visibility == CollPrivate || p.Collection.Visibility == CollProtected {
|
||||
return nil
|
||||
}
|
||||
|
||||
if debugging {
|
||||
if isUpdate {
|
||||
log.Info("Federating updated post!")
|
||||
|
@ -865,9 +609,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
log.Info("Federating new post!")
|
||||
}
|
||||
}
|
||||
|
||||
actor := p.Collection.PersonObject(collID)
|
||||
na := p.ActivityObject(app)
|
||||
na := p.ActivityObject(app.cfg)
|
||||
|
||||
// Add followers
|
||||
p.Collection.ID = collID
|
||||
|
@ -885,72 +628,36 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
inbox = f.Inbox
|
||||
}
|
||||
if _, ok := inboxes[inbox]; ok {
|
||||
// check if we're already sending to this shared inbox
|
||||
inboxes[inbox] = append(inboxes[inbox], f.ActorID)
|
||||
} else {
|
||||
// add the new shared inbox to the list
|
||||
inboxes[inbox] = []string{f.ActorID}
|
||||
}
|
||||
}
|
||||
|
||||
var activity *activitystreams.Activity
|
||||
// for each one of the shared inboxes
|
||||
for si, instFolls := range inboxes {
|
||||
// add all followers from that instance
|
||||
// to the CC field
|
||||
na.CC = []string{}
|
||||
na.CC = append(na.CC, instFolls...)
|
||||
// create a new "Create" activity
|
||||
// with our article as object
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
var activity *activitystreams.Activity
|
||||
if isUpdate {
|
||||
na.Updated = &p.Updated
|
||||
activity = activitystreams.NewUpdateActivity(na)
|
||||
} else {
|
||||
activity = activitystreams.NewCreateActivity(na)
|
||||
activity.To = na.To
|
||||
activity.CC = na.CC
|
||||
}
|
||||
// and post it to that sharedInbox
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, si, activity)
|
||||
if err != nil {
|
||||
log.Error("Couldn't post! %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// re-create the object so that the CC list gets reset and has
|
||||
// the mentioned users. This might seem wasteful but the code is
|
||||
// cleaner than adding the mentioned users to CC here instead of
|
||||
// in p.ActivityObject()
|
||||
na = p.ActivityObject(app)
|
||||
for _, tag := range na.Tag {
|
||||
if tag.Type == "Mention" {
|
||||
activity = activitystreams.NewCreateActivity(na)
|
||||
activity.To = na.To
|
||||
activity.CC = na.CC
|
||||
// This here might be redundant in some cases as we might have already
|
||||
// sent this to the sharedInbox of this instance above, but we need too
|
||||
// much logic to catch this at the expense of the odd extra request.
|
||||
// I don't believe we'd ever have too many mentions in a single post that this
|
||||
// could become a burden.
|
||||
remoteUser, err := getRemoteUser(app, tag.HRef)
|
||||
if err != nil {
|
||||
log.Error("Unable to find remote user %s. Skipping: %v", tag.HRef, err)
|
||||
continue
|
||||
}
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity)
|
||||
if err != nil {
|
||||
log.Error("Couldn't post! %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||
u := RemoteUser{ActorID: actorID}
|
||||
var urlVal, handle sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, url, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &urlVal, &handle)
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
||||
|
@ -959,26 +666,6 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
u.URL = urlVal.String
|
||||
u.Handle = handle.String
|
||||
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// getRemoteUserFromHandle retrieves the profile page of a remote user
|
||||
// from the @user@server.tld handle
|
||||
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
||||
u := RemoteUser{Handle: handle}
|
||||
var urlVal sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox, url FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox, &urlVal)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, ErrRemoteUserNotFound
|
||||
case err != nil:
|
||||
log.Error("Couldn't get remote user %s: %v", handle, err)
|
||||
return nil, err
|
||||
}
|
||||
u.URL = urlVal.String
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
|
@ -993,28 +680,13 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
|||
log.Info("Not found; fetching actor %s remotely", actorIRI)
|
||||
actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Unable to get base actor! %v", err)
|
||||
log.Error("Unable to get actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actor."}
|
||||
}
|
||||
if err := unmarshalActor(actorResp, actor); err != nil {
|
||||
log.Error("Unable to unmarshal base actor! %v", err)
|
||||
log.Error("Unable to unmarshal actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actor."}
|
||||
}
|
||||
baseActor := &activitystreams.Person{}
|
||||
if err := unmarshalActor(actorResp, baseActor); err != nil {
|
||||
log.Error("Unable to unmarshal actual actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."}
|
||||
}
|
||||
// Fetch the actual actor using the owner field from the publicKey object
|
||||
actualActorResp, err := resolveIRI(app.cfg.App.Host, baseActor.PublicKey.Owner)
|
||||
if err != nil {
|
||||
log.Error("Unable to get actual actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actual actor."}
|
||||
}
|
||||
if err := unmarshalActor(actualActorResp, actor); err != nil {
|
||||
log.Error("Unable to unmarshal actual actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."}
|
||||
}
|
||||
} else {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
@ -1027,69 +699,6 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
|||
return actor, remoteUser, nil
|
||||
}
|
||||
|
||||
func GetProfileURLFromHandle(app *App, handle string) (string, error) {
|
||||
handle = strings.TrimLeft(handle, "@")
|
||||
actorIRI := ""
|
||||
parts := strings.Split(handle, "@")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid handle format")
|
||||
}
|
||||
domain := parts[1]
|
||||
|
||||
// Check non-AP instances
|
||||
if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" {
|
||||
return siloProfileURL, nil
|
||||
}
|
||||
|
||||
remoteUser, err := getRemoteUserFromHandle(app, handle)
|
||||
if err != nil {
|
||||
// can't find using handle in the table but the table may already have this user without
|
||||
// handle from a previous version
|
||||
// TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all
|
||||
actorIRI = RemoteLookup(handle)
|
||||
_, errRemoteUser := getRemoteUser(app, actorIRI)
|
||||
// if it exists then we need to update the handle
|
||||
if errRemoteUser == nil {
|
||||
_, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||
}
|
||||
} else {
|
||||
// this probably means we don't have the user in the table so let's try to insert it
|
||||
// here we need to ask the server for the inboxes
|
||||
remoteActor, err := activityserve.NewRemoteActor(actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Couldn't fetch remote actor: %v", err)
|
||||
}
|
||||
if debugging {
|
||||
log.Info("Got remote actor: %s %s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||
}
|
||||
_, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url, handle) VALUES(?, ?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||
if err != nil {
|
||||
log.Error("Couldn't insert remote user: %v", err)
|
||||
return "", err
|
||||
}
|
||||
actorIRI = remoteActor.URL()
|
||||
}
|
||||
} else if remoteUser.URL == "" {
|
||||
log.Info("Remote user %s URL empty, fetching", remoteUser.ActorID)
|
||||
newRemoteActor, err := activityserve.NewRemoteActor(remoteUser.ActorID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't fetch remote actor: %v", err)
|
||||
} else {
|
||||
_, err := app.db.Exec("UPDATE remoteusers SET url = ? WHERE actor_id = ?", newRemoteActor.URL(), remoteUser.ActorID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||
} else {
|
||||
actorIRI = newRemoteActor.URL()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
actorIRI = remoteUser.URL
|
||||
}
|
||||
return actorIRI, nil
|
||||
}
|
||||
|
||||
// unmarshal actor normalizes the actor response to conform to
|
||||
// the type Person from github.com/writeas/web-core/activitysteams
|
||||
//
|
||||
|
@ -1134,35 +743,3 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parsePostIDFromURL(app *App, u *url.URL) (string, error) {
|
||||
// Get post ID from URL
|
||||
var collAlias, slug, postID string
|
||||
if m := apCollectionPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 3 {
|
||||
collAlias = m[1]
|
||||
slug = m[2]
|
||||
} else if m = apDraftPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 2 {
|
||||
postID = m[1]
|
||||
} else {
|
||||
return "", fmt.Errorf("unable to match objectIRI: %s", u)
|
||||
}
|
||||
|
||||
// Get postID if all we have is collection and slug
|
||||
if collAlias != "" && slug != "" {
|
||||
c, err := app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p, err := app.db.GetPost(slug, c.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
postID = p.ID
|
||||
}
|
||||
|
||||
return postID, nil
|
||||
}
|
||||
|
||||
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||
}
|
||||
|
|
217
admin.go
217
admin.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,7 +13,6 @@ package writefreely
|
|||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
@ -25,8 +24,8 @@ import (
|
|||
"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/appstats"
|
||||
"github.com/writeas/writefreely/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -91,71 +90,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 {
|
||||
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 +116,28 @@ func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Re
|
|||
ConfigMessage: r.FormValue("cm"),
|
||||
}
|
||||
|
||||
showUserPage(w, "monitor", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminSettings(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)
|
||||
showUserPage(w, "admin", 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)
|
||||
|
@ -240,7 +169,6 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
|
@ -251,20 +179,15 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
TotalPosts int64
|
||||
ClearEmail string
|
||||
}{
|
||||
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()}
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)}
|
||||
}
|
||||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
@ -318,37 +241,6 @@ 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"]
|
||||
|
@ -365,13 +257,10 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht
|
|||
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)}
|
||||
log.Error("toggle user suspended: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v")}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
|
||||
}
|
||||
|
@ -411,16 +300,14 @@ func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.
|
|||
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 +317,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 +327,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 +342,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",
|
||||
|
@ -489,24 +364,20 @@ 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" {
|
||||
|
@ -541,7 +412,7 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req
|
|||
id := vars["page"]
|
||||
|
||||
// Validate
|
||||
if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" {
|
||||
if id != "about" && id != "privacy" && id != "landing" && id != "reader" {
|
||||
return impart.HTTPError{http.StatusNotFound, "No such page."}
|
||||
}
|
||||
|
||||
|
@ -573,7 +444,6 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
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"
|
||||
mul, err := strconv.Atoi(r.FormValue("min_username_len"))
|
||||
if err == nil {
|
||||
apper.App().cfg.App.MinUsernameLen = mul
|
||||
|
@ -584,7 +454,6 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
}
|
||||
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 {
|
||||
|
@ -602,7 +471,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
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() {
|
||||
|
@ -655,39 +524,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
|
||||
}
|
||||
|
|
246
app.go
246
app.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,10 +13,9 @@ package writefreely
|
|||
import (
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
@ -31,25 +30,23 @@ import (
|
|||
"github.com/gorilla/schema"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/manifoldco/promptui"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/go-strip-markdown"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/converter"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/key"
|
||||
"github.com/writeas/writefreely/migrations"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"github.com/writefreely/writefreely/migrations"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
const (
|
||||
staticDir = "static"
|
||||
assumedTitleLen = 80
|
||||
postsPerPage = 10
|
||||
postsPerArchPage = 40
|
||||
staticDir = "static"
|
||||
assumedTitleLen = 80
|
||||
postsPerPage = 10
|
||||
|
||||
serverSoftware = "WriteFreely"
|
||||
softwareURL = "https://writefreely.org"
|
||||
|
@ -59,7 +56,7 @@ var (
|
|||
debugging bool
|
||||
|
||||
// Software version can be set from git env using -ldflags
|
||||
softwareVer = "0.15.1"
|
||||
softwareVer = "0.11.0"
|
||||
|
||||
// DEPRECATED VARS
|
||||
isSingleUser bool
|
||||
|
@ -73,9 +70,8 @@ type App struct {
|
|||
cfg *config.Config
|
||||
cfgFile string
|
||||
keys *key.Keychain
|
||||
sessionStore sessions.Store
|
||||
sessionStore *sessions.CookieStore
|
||||
formDecoder *schema.Decoder
|
||||
updates *updatesCache
|
||||
|
||||
timeline *localTimeline
|
||||
}
|
||||
|
@ -105,14 +101,6 @@ func (app *App) SetKeys(k *key.Keychain) {
|
|||
app.keys = k
|
||||
}
|
||||
|
||||
func (app *App) SessionStore() sessions.Store {
|
||||
return app.sessionStore
|
||||
}
|
||||
|
||||
func (app *App) SetSessionStore(s sessions.Store) {
|
||||
app.sessionStore = s
|
||||
}
|
||||
|
||||
// Apper is the interface for getting data into and out of a WriteFreely
|
||||
// instance (or "App").
|
||||
//
|
||||
|
@ -169,15 +157,7 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", emailKeyPath)
|
||||
}
|
||||
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
executable = "writefreely"
|
||||
} else {
|
||||
executable = filepath.Base(executable)
|
||||
}
|
||||
|
||||
app.keys.EmailKey, err = os.ReadFile(emailKeyPath)
|
||||
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -185,7 +165,7 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", cookieAuthKeyPath)
|
||||
}
|
||||
app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath)
|
||||
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -193,27 +173,11 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", cookieKeyPath)
|
||||
}
|
||||
app.keys.CookieKey, err = os.ReadFile(cookieKeyPath)
|
||||
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if debugging {
|
||||
log.Info(" %s", csrfKeyPath)
|
||||
}
|
||||
app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Error(`Missing key: %s.
|
||||
|
||||
Run this command to generate missing keys:
|
||||
%s keys generate
|
||||
|
||||
`, csrfKeyPath, executable)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -248,10 +212,6 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
return handleViewPad(app, w, r)
|
||||
}
|
||||
|
||||
if app.cfg.App.Private {
|
||||
return viewLogin(app, w, r)
|
||||
}
|
||||
|
||||
if land := app.cfg.App.LandingPath(); land != "/" {
|
||||
return impart.HTTPError{http.StatusFound, land}
|
||||
}
|
||||
|
@ -265,7 +225,6 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Flashes []template.HTML
|
||||
Banner template.HTML
|
||||
Content template.HTML
|
||||
|
@ -273,7 +232,6 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
ForcedLanding bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
ForcedLanding: forceLanding,
|
||||
}
|
||||
|
||||
|
@ -318,7 +276,7 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
|||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
}
|
||||
if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" {
|
||||
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
|
||||
var c *instanceContent
|
||||
var err error
|
||||
|
||||
|
@ -329,12 +287,6 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
|||
p.AboutStats = &InstanceStats{}
|
||||
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
|
||||
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
|
||||
} else if r.URL.Path == "/contact" {
|
||||
c, err = getContactPage(app)
|
||||
if c.Updated.IsZero() {
|
||||
// Page was never set up, so return 404
|
||||
return ErrPostNotFound
|
||||
}
|
||||
} else {
|
||||
c, err = getPrivacyPage(app)
|
||||
}
|
||||
|
@ -365,11 +317,6 @@ func pageForReq(app *App, r *http.Request) page.StaticPage {
|
|||
Version: "v" + softwareVer,
|
||||
}
|
||||
|
||||
// Use custom style, if file exists
|
||||
if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil {
|
||||
p.CustomCSS = true
|
||||
}
|
||||
|
||||
// Add user information, if given
|
||||
var u *User
|
||||
accessToken := r.FormValue("t")
|
||||
|
@ -416,8 +363,6 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("init keys: %s", err)
|
||||
}
|
||||
apper.App().InitUpdates()
|
||||
|
||||
apper.App().InitSession()
|
||||
|
||||
apper.App().InitDecoder()
|
||||
|
@ -427,15 +372,6 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
|||
return nil, fmt.Errorf("connect to DB: %s", err)
|
||||
}
|
||||
|
||||
initActivityPub(apper.App())
|
||||
|
||||
if apper.App().cfg.Email.Enabled() {
|
||||
log.Info("Starting publish jobs queue...")
|
||||
go startPublishJobsQueue(apper.App())
|
||||
} else {
|
||||
log.Error("[FAILED] Starting publish jobs queue: no email provider is configured.")
|
||||
}
|
||||
|
||||
// Handle local timeline, if enabled
|
||||
if apper.App().cfg.App.LocalTimeline {
|
||||
log.Info("Initializing local timeline...")
|
||||
|
@ -462,11 +398,6 @@ func Serve(app *App, r *mux.Router) {
|
|||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Start gopher server
|
||||
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
|
||||
go initGopher(app)
|
||||
}
|
||||
|
||||
// Start web application server
|
||||
var bindAddress = app.cfg.Server.Bind
|
||||
if bindAddress == "" {
|
||||
|
@ -524,41 +455,9 @@ requests. We recommend supplying a valid host name.`)
|
|||
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
|
||||
}
|
||||
} else {
|
||||
network := "tcp"
|
||||
protocol := "http"
|
||||
if strings.HasPrefix(bindAddress, "/") {
|
||||
network = "unix"
|
||||
protocol = "http+unix"
|
||||
|
||||
// old sockets will remain after server closes;
|
||||
// we need to delete them in order to open new ones
|
||||
err = os.Remove(bindAddress)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Error("%s already exists but could not be removed: %v", bindAddress, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port)
|
||||
}
|
||||
|
||||
log.Info("Serving on %s://%s", protocol, bindAddress)
|
||||
log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
|
||||
log.Info("---")
|
||||
listener, err := net.Listen(network, bindAddress)
|
||||
if err != nil {
|
||||
log.Error("Could not bind to address: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
err = os.Chmod(bindAddress, 0o666)
|
||||
if err != nil {
|
||||
log.Error("Could not update socket permissions: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
defer listener.Close()
|
||||
err = http.Serve(listener, r)
|
||||
err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Unable to start: %v", err)
|
||||
|
@ -582,8 +481,8 @@ func (app *App) InitDecoder() {
|
|||
// tests the connection.
|
||||
func ConnectToDatabase(app *App) error {
|
||||
// Check database configuration
|
||||
if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" {
|
||||
return fmt.Errorf("Database user not set.")
|
||||
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
|
||||
return fmt.Errorf("Database user or password not set.")
|
||||
}
|
||||
if app.cfg.Database.Host == "" {
|
||||
app.cfg.Database.Host = "localhost"
|
||||
|
@ -674,7 +573,7 @@ func DoConfig(app *App, configSections string) {
|
|||
|
||||
// Create blog
|
||||
log.Info("Creating user %s...\n", u.Username)
|
||||
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "")
|
||||
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName)
|
||||
if err != nil {
|
||||
log.Error("Unable to create user: %s", err)
|
||||
os.Exit(1)
|
||||
|
@ -714,10 +613,6 @@ func GenerateKeyFiles(app *App) error {
|
|||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
err = generateKey(csrfKeyPath)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
|
||||
return keyErrs
|
||||
}
|
||||
|
@ -786,59 +681,13 @@ func ResetPassword(apper Apper, username string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// DoDeleteAccount runs the confirmation and account delete process.
|
||||
func DoDeleteAccount(apper Apper, username string) error {
|
||||
// Connect to the database
|
||||
apper.LoadConfig()
|
||||
connectToDatabase(apper.App())
|
||||
defer shutdown(apper.App())
|
||||
|
||||
// check user exists
|
||||
u, err := apper.App().db.GetUserForAuth(username)
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
userID := u.ID
|
||||
|
||||
// do not delete the admin account
|
||||
// TODO: check for other admins and skip?
|
||||
if u.IsAdmin() {
|
||||
log.Error("Can not delete admin account")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// confirm deletion, w/ w/out posts
|
||||
prompt := promptui.Prompt{
|
||||
Templates: &promptui.PromptTemplates{
|
||||
Success: "{{ . | bold | faint }}: ",
|
||||
},
|
||||
Label: fmt.Sprintf("Really delete user : %s", username),
|
||||
IsConfirm: true,
|
||||
}
|
||||
_, err = prompt.Run()
|
||||
if err != nil {
|
||||
log.Info("Aborted...")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
log.Info("Deleting...")
|
||||
err = apper.App().db.DeleteAccount(userID)
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("Success.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectToDatabase(app *App) {
|
||||
log.Info("Connecting to %s database...", app.cfg.Database.Type)
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
if app.cfg.Database.Type == driverMySQL {
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS))
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String())))
|
||||
db.SetMaxOpenConns(50)
|
||||
} else if app.cfg.Database.Type == driverSQLite {
|
||||
if !SQLiteEnabled {
|
||||
|
@ -850,7 +699,7 @@ func connectToDatabase(app *App) {
|
|||
os.Exit(1)
|
||||
}
|
||||
db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared")
|
||||
db.SetMaxOpenConns(2)
|
||||
db.SetMaxOpenConns(1)
|
||||
} else {
|
||||
log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type)
|
||||
os.Exit(1)
|
||||
|
@ -865,16 +714,6 @@ func connectToDatabase(app *App) {
|
|||
func shutdown(app *App) {
|
||||
log.Info("Closing database connection...")
|
||||
app.db.Close()
|
||||
if strings.HasPrefix(app.cfg.Server.Bind, "/") {
|
||||
// Clean up socket
|
||||
log.Info("Removing socket file...")
|
||||
err := os.Remove(app.cfg.Server.Bind)
|
||||
if err != nil {
|
||||
log.Error("Unable to remove socket: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("Success.")
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser creates a new admin or normal user from the given credentials.
|
||||
|
@ -889,12 +728,12 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
if isAdmin {
|
||||
// Abort if trying to create admin user, but one already exists
|
||||
if firstUser != nil {
|
||||
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely user create [USER]:[PASSWORD]", firstUser.Username)
|
||||
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
|
||||
}
|
||||
} else {
|
||||
// Abort if trying to create regular user, but no admin exists yet
|
||||
if firstUser == nil {
|
||||
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely user create --admin [USER]:[PASSWORD]")
|
||||
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -929,7 +768,7 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
userType = "admin"
|
||||
}
|
||||
log.Info("Creating %s %s...", userType, usernameDesc)
|
||||
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "")
|
||||
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to create user: %s", err)
|
||||
}
|
||||
|
@ -937,18 +776,15 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
//go:embed schema.sql
|
||||
var schemaSql string
|
||||
|
||||
//go:embed sqlite.sql
|
||||
var sqliteSql string
|
||||
|
||||
func adminInitDatabase(app *App) error {
|
||||
var schema string
|
||||
schemaFileName := "schema.sql"
|
||||
if app.cfg.Database.Type == driverSQLite {
|
||||
schema = sqliteSql
|
||||
} else {
|
||||
schema = schemaSql
|
||||
schemaFileName = "sqlite.sql"
|
||||
}
|
||||
|
||||
schema, err := Asset(schemaFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to load schema file: %v", err)
|
||||
}
|
||||
|
||||
tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
|
||||
|
@ -964,7 +800,7 @@ func adminInitDatabase(app *App) error {
|
|||
} else {
|
||||
log.Info("Creating table ??? (Weird query) No match in: %v", parts)
|
||||
}
|
||||
_, err := app.db.Exec(q)
|
||||
_, err = app.db.Exec(q)
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
} else {
|
||||
|
@ -974,7 +810,7 @@ func adminInitDatabase(app *App) error {
|
|||
|
||||
// Set up migrations table
|
||||
log.Info("Initializing appmigrations table...")
|
||||
err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
||||
err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to set initial migrations: %v", err)
|
||||
}
|
||||
|
@ -988,13 +824,3 @@ func adminInitDatabase(app *App) error {
|
|||
log.Info("Done.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServerUserAgent returns a User-Agent string to use in external requests. The
|
||||
// hostName parameter may be left empty.
|
||||
func ServerUserAgent(hostName string) string {
|
||||
hostUAStr := ""
|
||||
if hostName != "" {
|
||||
hostUAStr = "; +" + hostName
|
||||
}
|
||||
return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")"
|
||||
}
|
||||
|
|
2
auth.go
2
auth.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -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 {
|
||||
|
|
105
bindata-lib.go
Normal file
105
bindata-lib.go
Normal file
|
@ -0,0 +1,105 @@
|
|||
// +build wflib
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func bindata_read(data []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, gz)
|
||||
gz.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var _schema_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xd4\x59\x5f\x6f\xa3\x38\x10\x7f\xef\xa7\xf0\xdb\xa6\x52\x23\x6d\x7a\xdd\xaa\xba\xd3\x3e\x64\x53\x76\x2f\xba\x94\xee\x25\x44\xba\x7d\x02\x03\x93\xd4\xaa\xb1\x91\x6d\x92\xe6\xdb\x9f\x8c\x49\x08\x86\x24\xd0\xdb\x3b\x71\x7d\x2a\xcc\x6f\x8c\xfd\x9b\x3f\x9e\x99\x0c\x87\x57\xc3\x21\x7a\xc4\x0a\x87\x58\xc2\xaf\x28\xd8\x0a\xa2\x60\x25\x00\xe8\x2e\xb8\x1a\x0e\xaf\xb4\x78\xf8\xce\x3f\xad\xac\xf5\x3d\x1c\x52\x40\x52\x89\x2c\x52\x99\x00\xb4\xe2\x02\xa9\xfc\x5d\x80\xa3\x08\xa4\x54\xfc\x15\x98\x34\xdf\x9b\xcc\x9d\xb1\xe7\x20\x6f\xfc\x65\xe6\xa0\xe9\x57\xe4\x3e\x7b\xc8\xf9\x6b\xba\xf0\x16\x16\x1a\x0d\xae\x10\x0a\xf2\x87\x00\x85\x84\x61\xb1\x1b\x8c\xee\xaf\x73\x05\x77\x39\x9b\xdd\x68\x71\x26\x41\xf8\x24\x0e\x10\x61\x6a\x60\x0b\x65\x16\xf3\x00\x29\xc2\x76\x5a\x3a\x2a\xa5\xe8\xd1\xf9\x3a\x5e\xce\x3c\xf4\xe1\xe3\x87\x1c\xc9\x19\xf8\x8a\x24\xd0\x0e\x1d\x09\xc0\x0a\xe2\x00\xc5\x58\x81\x56\xab\x43\x27\xcb\xf9\xdc\x71\x3d\xdf\x9b\x3e\x39\x0b\x6f\xfc\xf4\x3d\x57\x84\xb7\x94\x08\x90\x47\x8a\x7b\x7c\xf5\x40\x78\x0d\x4c\x05\x68\x83\x45\xf4\x82\xc5\xe0\xf6\xd3\xa7\xeb\x1a\xf2\xfb\x7c\xfa\x34\x9e\xff\x40\x7f\x38\x3f\xd0\xa0\xa0\xe9\xfa\xea\x1a\x39\xee\xb7\xa9\xeb\x7c\x9e\x32\xc6\x1f\xbf\x94\xfb\xf9\x7d\x3c\x5f\x38\xde\x67\x8a\x15\x61\xa3\xdf\xfe\x75\xb3\xa7\x69\xc4\x99\xd2\xa7\xb8\x6c\xf4\x12\x6b\x4c\xae\xcd\xb9\x3f\xfa\x2f\xb6\x4d\x0f\xd0\x04\x62\x92\x25\x0a\xde\x54\x7e\xb8\xf1\xc4\x73\xe6\x68\xe1\x78\x28\x53\xab\x07\x34\x79\x9e\xcd\xf4\x17\xf5\x83\x1f\x12\x66\x79\x4d\x1a\xbf\xcb\x80\x55\xce\x49\xdc\x2b\xc2\x13\xb2\x16\x58\x11\xde\x18\x68\x16\xc0\x10\xbd\x01\x21\x09\x67\x26\x78\x46\x23\x8b\x69\x03\x6f\x64\x29\x97\x0b\x90\x19\x55\x01\xca\x4d\xb0\x97\xf4\x85\x8f\x88\x53\x0a\x91\x3e\x2c\x56\x4a\x90\x30\x53\xd0\x22\xff\x34\x6a\x19\xae\x4a\xd1\xc9\x74\x73\xd0\x29\xdd\x77\x74\xfb\x60\x81\x36\x98\x66\x60\x85\x76\xdd\x7f\x93\xf0\xae\xe2\xc2\x49\x78\x57\xf3\xe2\xaa\x33\x56\xf7\x77\x73\xb4\x99\xde\xf8\x68\xb9\xc5\x57\xd8\x75\xb2\x46\x8e\x6f\x6d\x87\x34\x0b\x29\x89\xfc\x57\xd8\x05\x28\xa4\x3c\xb4\xa4\x82\x6c\xb0\x82\x13\xe2\x73\xa4\xf6\x90\xc8\x14\x4b\xb9\xe5\x22\xee\xc4\x66\xa9\xd4\x9e\xd2\x42\x25\x40\xb9\xd7\xde\x7f\xbc\xfe\x3f\xb3\x26\x20\x26\x02\x22\xd5\x89\xb5\x52\xc9\xb0\x96\x0a\xd8\xf8\x98\x12\x2c\x8f\xc2\xfd\xa3\x45\x4c\xc0\x60\x7b\x11\x54\x65\xef\x68\xdd\x1e\x52\xd7\x89\x32\x79\x74\xa1\x5b\x5e\x85\xc6\x4b\xef\xd9\x9f\xba\x93\xb9\xf3\xe4\xb8\x9e\xc9\x9f\x0d\x3c\xb5\x4f\x8d\xb5\x4a\x4a\x11\x45\x7f\x4e\xa6\x0d\x62\x90\x91\x20\xa9\xca\x2f\xcb\xc3\xfe\xee\x3b\xed\xaf\x5a\x99\xaa\x1d\x05\x5f\xbe\x00\x14\x17\xa8\x79\x9b\x7f\xa4\xb8\x51\x5b\xaf\x9c\xab\xae\xb8\x48\xf0\x51\xc9\xf8\x50\x2f\x18\x4d\xe6\x8b\x76\x8d\x35\xae\xa9\x82\xb7\xec\x4c\x35\xbd\x21\xb0\xf5\x23\x9e\xe9\xe2\xab\x41\x5e\xaf\x8d\xf4\xdb\xa5\x3b\xfd\x73\xe9\xe4\x2f\xf7\xf6\x1d\x04\x3d\xf3\xee\x94\xcb\x36\xa9\xc0\xc0\x4a\x8f\x2e\x9c\xc0\xee\x39\x68\xb6\xb6\x7c\xb8\x66\x88\x84\xc7\x64\xb5\xf3\x8b\xd6\xc6\xd4\xb9\xb7\x0d\x38\xed\x07\x3e\x4e\x53\xc0\x02\xb3\x08\x0a\xe8\x5d\x53\x67\xc2\xb8\x48\x4c\x73\x42\x31\x5b\x67\x78\xbd\x47\x37\xad\x2b\x14\xad\x38\xc1\x4f\xf0\x94\xda\x12\xcd\x97\x4a\xfd\x4b\x84\x31\x88\xfd\x94\x4b\x62\xa2\xeb\xe8\x8b\x4b\x77\x31\xfd\xe6\x3a\x8f\x0d\x8b\xef\x1b\x30\x5d\x95\x4a\x85\x93\xb4\x6d\x07\x76\xa8\xfc\x3b\x6b\x5e\x70\x7f\x3b\xdd\xfc\x93\xec\x70\xe8\x71\xba\x25\x82\x8e\xe1\x48\x62\xdf\x38\x6b\xbd\x78\xcc\xdf\xd7\x14\x4a\xa3\x0f\xca\xff\x6f\x0e\x6b\xe7\x98\xc2\x73\x0a\xd4\xde\x8f\x6e\x7a\xd5\x2b\x09\x48\xb8\x82\x15\xa7\x94\x6f\x5b\xc4\x7d\x15\x7e\xb2\x64\xaa\xf5\x4f\x46\xcf\xaf\x4c\x28\x6a\xa0\xd3\xa3\x84\xcb\x25\xbe\xf5\x81\x9e\xf1\xab\xb7\xd5\xae\xce\xb7\xf0\xf5\x21\x40\x7e\x75\x77\xe7\xf6\x6c\x1f\x70\x39\x3e\x8c\xc5\x0f\x1e\xdf\x7f\xb6\x3b\x51\x6d\xd7\x66\xc7\xec\x35\x16\x67\x91\xe2\x86\x8a\xd3\x56\x21\x2c\xe4\x6f\xe7\x00\xf2\x05\x0b\x88\xfd\x4b\xb8\xcb\xb6\xb1\xe2\x6f\x50\x6e\xaf\x37\x76\xd1\x24\x77\x99\x3d\x58\x78\x63\x9d\xb3\xe3\xcd\x86\x79\xc3\xfd\xdd\x7f\x34\x6e\xd8\x6f\xac\x97\x83\x06\xbd\x39\xc2\x36\xa4\x99\xf7\x8a\xd8\x2a\xe7\x6c\x8a\xab\x75\x4e\x7d\x44\x86\xdf\x74\x42\x90\x01\x92\x09\xa6\xf4\x64\x2d\x74\x36\xc9\xb7\x99\x0a\x13\x86\x23\x45\x36\xcd\xf3\xe9\x3e\xd1\xde\xd2\xd1\x3b\x76\x86\x5a\x85\xe1\x04\xde\xdd\x1c\x5e\x1a\x66\x54\x57\x32\x7c\x1d\x16\x32\x8f\xf5\x75\x20\xc1\x84\xe6\x5b\x2a\x7e\x9d\x68\x9c\xd3\xbf\xfb\xd7\x82\xcb\x59\xb0\xa4\x65\x50\xfe\xdf\xab\x28\x94\x26\xce\xe2\x53\x61\x78\x90\x17\xee\x90\x3f\xf9\x27\xc3\xf1\xe4\x7d\xdf\xfa\xcc\x7f\x07\x00\x00\xff\xff\xbe\x79\x68\xa8\x10\x1b\x00\x00")
|
||||
|
||||
func schema_sql() ([]byte, error) {
|
||||
return bindata_read(
|
||||
_schema_sql,
|
||||
"schema.sql",
|
||||
)
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
return f()
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
|
||||
// AssetNames returns the names of the assets.
|
||||
func AssetNames() []string {
|
||||
names := make([]string, 0, len(_bindata))
|
||||
for name := range _bindata {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string]func() ([]byte, error){
|
||||
"schema.sql": schema_sql,
|
||||
}
|
||||
// AssetDir returns the file names below a certain
|
||||
// directory embedded in the file by go-bindata.
|
||||
// For example if you run go-bindata on data/... and data contains the
|
||||
// following hierarchy:
|
||||
// data/
|
||||
// foo.txt
|
||||
// img/
|
||||
// a.png
|
||||
// b.png
|
||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
||||
// AssetDir("") will return []string{"data"}.
|
||||
func AssetDir(name string) ([]string, error) {
|
||||
node := _bintree
|
||||
if len(name) != 0 {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
pathList := strings.Split(cannonicalName, "/")
|
||||
for _, p := range pathList {
|
||||
node = node.Children[p]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Func != nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
rv := make([]string, 0, len(node.Children))
|
||||
for name := range node.Children {
|
||||
rv = append(rv, name)
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
type _bintree_t struct {
|
||||
Func func() ([]byte, error)
|
||||
Children map[string]*_bintree_t
|
||||
}
|
||||
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
|
||||
"schema.sql": &_bintree_t{schema_sql, map[string]*_bintree_t{
|
||||
}},
|
||||
}}
|
2
cache.go
2
cache.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,156 +11,113 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/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)
|
||||
}
|
||||
// General options usable with other commands
|
||||
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
|
||||
configFile := flag.String("c", "config.ini", "The configuration file to use")
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
||||
// Setup actions
|
||||
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
|
||||
doConfig := flag.Bool("config", false, "Run the configuration process")
|
||||
configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+
|
||||
"valid values are any combination of 'server', 'db' and 'app' "+
|
||||
"example: writefreely --config --sections \"db app\"")
|
||||
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
|
||||
createSchema := flag.Bool("init-db", false, "Initialize app database")
|
||||
migrate := flag.Bool("migrate", false, "Migrate the database")
|
||||
|
||||
app.Flags = append(app.Flags, defaultFlags...)
|
||||
// Admin actions
|
||||
createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password")
|
||||
createUser := flag.String("create-user", "", "Create a regular user with the given username:password")
|
||||
resetPassUser := flag.String("reset-pass", "", "Reset the given user's password")
|
||||
outputVersion := flag.Bool("v", false, "Output the current version")
|
||||
flag.Parse()
|
||||
|
||||
app.Commands = []*cli.Command{
|
||||
&cmdUser,
|
||||
&cmdDB,
|
||||
&cmdConfig,
|
||||
&cmdKeys,
|
||||
&cmdServe,
|
||||
}
|
||||
app := writefreely.NewApp(*configFile)
|
||||
|
||||
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 *outputVersion {
|
||||
writefreely.OutputVersion()
|
||||
os.Exit(0)
|
||||
} else if *createConfig {
|
||||
err := writefreely.CreateConfig(app)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
return writefreely.CreateUser(app, username, password, true)
|
||||
case c.IsSet("create-user"):
|
||||
username, password, err := parseCredentials(c.String("create-user"))
|
||||
os.Exit(0)
|
||||
} else if *doConfig {
|
||||
writefreely.DoConfig(app, *configSections)
|
||||
os.Exit(0)
|
||||
} else if *genKeys {
|
||||
err := writefreely.GenerateKeyFiles(app)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
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"))
|
||||
os.Exit(0)
|
||||
} else if *createSchema {
|
||||
err := writefreely.CreateSchema(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createAdmin != "" {
|
||||
username, password, err := userPass(*createAdmin, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createUser != "" {
|
||||
username, password, err := userPass(*createUser, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *resetPassUser != "" {
|
||||
err := writefreely.ResetPassword(app, *resetPassUser)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *migrate {
|
||||
err := writefreely.Migrate(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
var err error
|
||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||
app, err = writefreely.Initialize(app, *debugPtr)
|
||||
if err != nil {
|
||||
return err
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Set app routes
|
||||
|
@ -170,14 +127,20 @@ func legacyActions(c *cli.Context) error {
|
|||
|
||||
// Serve the application
|
||||
writefreely.Serve(app, r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCredentials(credentialString string) (string, string, error) {
|
||||
creds := strings.Split(credentialString, ":")
|
||||
func userPass(credStr string, isAdmin bool) (user string, pass string, err error) {
|
||||
creds := strings.Split(credStr, ":")
|
||||
if len(creds) != 2 {
|
||||
return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
|
||||
c := "user"
|
||||
if isAdmin {
|
||||
c = "admin"
|
||||
}
|
||||
err = fmt.Errorf("usage: writefreely --create-%s username:password", c)
|
||||
return
|
||||
}
|
||||
return creds[0], creds[1], nil
|
||||
|
||||
user = creds[0]
|
||||
pass = creds[1]
|
||||
return
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
473
collections.go
473
collections.go
|
@ -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,15 @@ 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/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -58,7 +47,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,9 +55,6 @@ 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
|
||||
}
|
||||
|
@ -78,25 +63,16 @@ type (
|
|||
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
|
||||
Suspended bool
|
||||
}
|
||||
|
||||
CollectionNav struct {
|
||||
*Collection
|
||||
Path string
|
||||
SingleUser bool
|
||||
CanPost bool
|
||||
}
|
||||
|
||||
SubmittedCollection struct {
|
||||
// Data used for updating a given collection
|
||||
ID int64
|
||||
|
@ -107,21 +83,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 +105,6 @@ type (
|
|||
|
||||
// User-related fields
|
||||
isCollOwner bool
|
||||
|
||||
isAuthorized bool
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -206,11 +175,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,17 +224,13 @@ 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")
|
||||
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/writeas/writefreely/issues/new?template=bug_report.md")
|
||||
}
|
||||
if isSingleUser {
|
||||
return c.hostName + "/"
|
||||
|
@ -281,16 +241,16 @@ func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
|
|||
|
||||
// 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 +260,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 {
|
||||
|
@ -379,51 +338,6 @@ 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)
|
||||
alias := r.FormValue("alias")
|
||||
|
@ -483,13 +397,13 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
userID = u.ID
|
||||
}
|
||||
silenced, err := app.db.IsUserSilenced(userID)
|
||||
suspended, err := app.db.IsUserSuspended(userID)
|
||||
if err != nil {
|
||||
log.Error("new collection: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
}
|
||||
|
||||
if !author.IsValidUsername(app.cfg, c.Alias) {
|
||||
|
@ -533,7 +447,8 @@ 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) {
|
||||
accept := r.Header.Get("Accept")
|
||||
if strings.Contains(accept, "application/activity+json") {
|
||||
return handleFetchCollectionActivities(app, w, r)
|
||||
}
|
||||
|
||||
|
@ -572,7 +487,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
res.Owner = u
|
||||
}
|
||||
}
|
||||
// TODO: check status for silenced
|
||||
// TODO: check suspended
|
||||
app.db.GetPostsCount(res, isCollOwner)
|
||||
// Strip non-public information
|
||||
res.Collection.ForPublic()
|
||||
|
@ -608,11 +523,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(app.cfg, 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 +535,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 +548,12 @@ 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(),
|
||||
}
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
}
|
||||
|
||||
func (c *CollectionObj) ScriptDisplay() template.JS {
|
||||
|
@ -773,20 +648,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
|
||||
|
@ -828,28 +693,26 @@ func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPost
|
|||
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
|
||||
|
@ -875,38 +738,24 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
suspended, err := app.db.IsUserSuspended(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,21 +764,14 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
return impart.HTTPError{http.StatusFound, redirURL}
|
||||
}
|
||||
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false, "")
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
|
||||
|
||||
// Serve collection
|
||||
displayPage := CollectionPage{
|
||||
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)
|
||||
|
@ -937,7 +779,6 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
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
|
||||
|
@ -959,24 +800,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
log.Error("Error getting user for collection: %v", err)
|
||||
}
|
||||
}
|
||||
if !isOwner && silenced {
|
||||
if !isOwner && suspended {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
displayPage.Silenced = isOwner && silenced
|
||||
displayPage.Suspended = isOwner && suspended
|
||||
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"
|
||||
} else if isArchiveView(r) {
|
||||
displayPage.NavSuffix = "/archive/"
|
||||
collTmpl = "collection-archive"
|
||||
}
|
||||
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
||||
if err != nil {
|
||||
|
@ -1003,23 +840,6 @@ 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 {
|
||||
vars := mux.Vars(r)
|
||||
tag := vars["tag"]
|
||||
|
@ -1042,23 +862,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
return err
|
||||
}
|
||||
|
||||
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 := newDisplayCollection(c, cr, page)
|
||||
|
||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
|
||||
if coll.Posts != nil && len(*coll.Posts) == 0 {
|
||||
|
@ -1066,7 +870,10 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
}
|
||||
|
||||
// Serve collection
|
||||
displayPage := TagCollectionPage{
|
||||
displayPage := struct {
|
||||
CollectionPage
|
||||
Tag string
|
||||
}{
|
||||
CollectionPage: CollectionPage{
|
||||
DisplayCollection: coll,
|
||||
StaticPage: pageForReq(app, r),
|
||||
|
@ -1098,17 +905,16 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
// 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()
|
||||
if !isOwner && u.IsSilenced() {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
displayPage.Suspended = u.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")
|
||||
|
||||
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
|
||||
if err != nil {
|
||||
|
@ -1118,111 +924,6 @@ 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 {
|
||||
vars := mux.Vars(r)
|
||||
slug := vars["slug"]
|
||||
|
@ -1262,14 +963,14 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
}
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
if err != nil {
|
||||
log.Error("existing collection: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
}
|
||||
|
||||
if r.Method == "DELETE" {
|
||||
|
@ -1307,7 +1008,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 {
|
||||
|
@ -1419,43 +1120,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
26
config.ini.example
Normal 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
|
||||
|
139
config/config.go
139
config/config.go
|
@ -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,8 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"gopkg.in/ini.v1"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ini/ini"
|
||||
"github.com/writeas/web-core/log"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -46,10 +42,6 @@ type (
|
|||
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 +54,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
|
||||
|
@ -136,22 +73,17 @@ type (
|
|||
|
||||
// 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"`
|
||||
Federation bool `ini:"federation"`
|
||||
PublicStats bool `ini:"public_stats"`
|
||||
|
||||
// Access
|
||||
Private bool `ini:"private"`
|
||||
|
@ -162,39 +94,13 @@ type (
|
|||
|
||||
// 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"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -250,21 +156,6 @@ func (ac *AppCfg) LandingPath() string {
|
|||
return ac.Landing
|
||||
}
|
||||
|
||||
func (lc EmailCfg) Enabled() bool {
|
||||
return (lc.Domain != "" && lc.MailgunPrivate != "") ||
|
||||
lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0
|
||||
}
|
||||
|
||||
func (ac AppCfg) SignupPath() string {
|
||||
if !ac.OpenRegistration {
|
||||
return ""
|
||||
}
|
||||
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 +172,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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,14 +12,12 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SetupData struct {
|
||||
|
@ -59,7 +57,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
Success: "{{ . | bold | faint }}: ",
|
||||
}
|
||||
selTmpls := &promptui.SelectTemplates{
|
||||
Selected: `{{.Label}} {{ . | faint }}`,
|
||||
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`),
|
||||
}
|
||||
|
||||
var selPrompt promptui.Select
|
||||
|
@ -82,8 +80,6 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
isDevEnv := envType == "Development"
|
||||
isStandalone := envType == "Production, standalone"
|
||||
|
||||
_, isDocker := os.LookupEnv("WRITEFREELY_DOCKER")
|
||||
|
||||
data.Config.Server.Dev = isDevEnv
|
||||
|
||||
if isDevEnv || !isStandalone {
|
||||
|
@ -154,16 +150,6 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
data.Config.Server.TLSKeyPath = ""
|
||||
}
|
||||
|
||||
// If running in docker:
|
||||
// 1. always bind to 0.0.0.0 instead of localhost
|
||||
// 2. set paths of static files in UNIX manners
|
||||
if !isDevEnv && isDocker {
|
||||
data.Config.Server.TemplatesParentDir = "/usr/share/writefreely"
|
||||
data.Config.Server.StaticParentDir = "/usr/share/writefreely"
|
||||
data.Config.Server.PagesParentDir = "/usr/share/writefreely"
|
||||
data.Config.Server.Bind = "0.0.0.0"
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
|
@ -370,7 +356,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
if data.Config.App.Federation {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Usage stats (active users, posts)",
|
||||
Label: "Federation usage stats",
|
||||
Items: []string{"Public", "Private"},
|
||||
}
|
||||
_, fedStatsType, err := selPrompt.Run()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
//go:build wflib
|
||||
// +build wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -19,11 +18,3 @@ 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
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
//go:build !sqlite && !wflib
|
||||
// +build !sqlite,!wflib
|
||||
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
//go:build sqlite && !wflib
|
||||
// +build sqlite,!wflib
|
||||
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
|
|
1035
database.go
1035
database.go
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
52
db/alter.go
52
db/alter.go
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
263
db/create.go
263
db/create.go
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
53
db/index.go
53
db/index.go
|
@ -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
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package db
|
||||
|
||||
type RawSqlBuilder struct {
|
||||
Query string
|
||||
}
|
||||
|
||||
func (b *RawSqlBuilder) ToSQL() (string, error) {
|
||||
return b.Query, nil
|
||||
}
|
25
db/tx.go
25
db/tx.go
|
@ -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()
|
||||
}
|
|
@ -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
|
|
@ -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
477
email.go
|
@ -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
|
||||
}
|
13
errors.go
13
errors.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 Musing Studio LLC.
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -37,8 +37,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 +45,10 @@ 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."}
|
||||
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
|
||||
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."}
|
||||
ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
|
||||
)
|
||||
|
||||
// Post operation errors
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -110,7 +110,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
|
|||
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 +119,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
|
|||
var collObjs []CollectionObj
|
||||
for _, c := range *colls {
|
||||
co := &CollectionObj{Collection: c}
|
||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true, "")
|
||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true)
|
||||
if err != nil {
|
||||
log.Error("unable to get collection posts: %v", err)
|
||||
}
|
||||
|
|
30
feed.go
30
feed.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -15,9 +15,9 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
. "github.com/gorilla/feeds"
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
|
@ -36,12 +36,12 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("view feed: get user: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if silenced {
|
||||
if suspended {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
@ -67,7 +67,7 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
|||
if tag != "" {
|
||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
|
||||
} else {
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false, "")
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false)
|
||||
}
|
||||
|
||||
author := ""
|
||||
|
@ -87,29 +87,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), "", app.cfg),
|
||||
Author: &Author{author, ""},
|
||||
Created: p.Created,
|
||||
Updated: p.Updated,
|
||||
})
|
||||
|
|
128
go.mod
128
go.mod
|
@ -1,98 +1,60 @@
|
|||
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/alecthomas/gometalinter v3.0.0+incompatible // indirect
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // 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/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/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/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/v2 v2.0.2
|
||||
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/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/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.2.0
|
||||
github.com/writefreely/go-nodeinfo v1.2.0
|
||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f
|
||||
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/ini.v1 v1.41.0
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||
)
|
||||
|
||||
go 1.21
|
||||
|
|
375
go.sum
375
go.sum
|
@ -1,18 +1,14 @@
|
|||
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/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/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=
|
||||
|
@ -22,150 +18,89 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
|||
github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.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-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/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/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/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/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/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/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 +108,67 @@ 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/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
|
||||
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
|
||||
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/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/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
|
||||
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
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
167
gopher.go
|
@ -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()
|
||||
}
|
183
handle.go
183
handle.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -23,9 +23,8 @@ 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/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
)
|
||||
|
||||
// UserLevel represents the required user level for accessing an endpoint
|
||||
|
@ -65,7 +64,6 @@ func UserLevelReader(cfg *config.Config) UserLevel {
|
|||
|
||||
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)
|
||||
|
@ -75,7 +73,7 @@ type (
|
|||
|
||||
type Handler struct {
|
||||
errors *ErrorPages
|
||||
sessionStore sessions.Store
|
||||
sessionStore *sessions.CookieStore
|
||||
app Apper
|
||||
}
|
||||
|
||||
|
@ -85,7 +83,6 @@ type ErrorPages struct {
|
|||
NotFound *template.Template
|
||||
Gone *template.Template
|
||||
InternalServerError *template.Template
|
||||
UnavailableError *template.Template
|
||||
Blank *template.Template
|
||||
}
|
||||
|
||||
|
@ -97,10 +94,9 @@ func NewHandler(apper Apper) *Handler {
|
|||
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(),
|
||||
sessionStore: apper.App().sessionStore,
|
||||
app: apper,
|
||||
}
|
||||
|
||||
|
@ -115,7 +111,6 @@ func NewWFHandler(apper Apper) *Handler {
|
|||
NotFound: pages["404-general.tmpl"],
|
||||
Gone: pages["410.tmpl"],
|
||||
InternalServerError: pages["500.tmpl"],
|
||||
UnavailableError: pages["503.tmpl"],
|
||||
Blank: pages["blank.tmpl"],
|
||||
})
|
||||
return h
|
||||
|
@ -155,14 +150,8 @@ func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
|
|||
err := f(h.app.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
|
||||
}
|
||||
|
@ -262,7 +251,7 @@ func apiAuth(app *App, r *http.Request) (*User, error) {
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// optionalAPIAuth is used for endpoints that accept authenticated requests via
|
||||
// optionaAPIAuth 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) {
|
||||
|
@ -293,26 +282,6 @@ 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return u, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
handleFunc := func() error {
|
||||
|
@ -580,69 +549,6 @@ 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 {
|
||||
|
@ -659,9 +565,6 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
|
|||
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
|
||||
|
@ -818,7 +721,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
return
|
||||
} else if err.Status == http.StatusNotFound {
|
||||
w.WriteHeader(err.Status)
|
||||
if IsActivityPubRequest(r) {
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||
// This is a fediverse request; simply return the header
|
||||
return
|
||||
}
|
||||
|
@ -829,10 +732,6 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
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))
|
||||
return
|
||||
} else if err.Status == http.StatusAccepted {
|
||||
impart.WriteSuccess(w, "", err.Status)
|
||||
return
|
||||
|
@ -880,45 +779,6 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
|
|||
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
|
||||
}
|
||||
|
||||
func correctPageFromLoginAttempt(r *http.Request) string {
|
||||
to := r.FormValue("to")
|
||||
if to == "" {
|
||||
|
@ -981,33 +841,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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
43
invites.go
43
invites.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -19,9 +19,9 @@ import (
|
|||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
"github.com/writeas/writefreely/page"
|
||||
)
|
||||
|
||||
type Invite struct {
|
||||
|
@ -42,18 +42,6 @@ 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")
|
||||
}
|
||||
|
@ -68,22 +56,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
|
||||
|
@ -101,7 +79,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
|
|||
expVal := r.FormValue("expires")
|
||||
|
||||
if u.IsSilenced() {
|
||||
return ErrUserSilenced
|
||||
return ErrUserSuspended
|
||||
}
|
||||
|
||||
var err error
|
||||
|
@ -124,7 +102,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
|
||||
|
@ -173,23 +151,18 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
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 {
|
||||
p.Error = "This invite link has expired."
|
||||
}
|
||||
|
||||
// Tell search engines not to index invite links
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
|
||||
// Get error messages
|
||||
session, err := app.sessionStore.Get(r, cookieName)
|
||||
if err != nil {
|
||||
|
|
72
jobs.go
72
jobs.go
|
@ -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
|
||||
}
|
10
key/key.go
10
key/key.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019, 2021 Musing Studio LLC.
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -20,7 +20,7 @@ const (
|
|||
)
|
||||
|
||||
type Keychain struct {
|
||||
EmailKey, CookieAuthKey, CookieKey, CSRFKey []byte
|
||||
EmailKey, CookieAuthKey, CookieKey []byte
|
||||
}
|
||||
|
||||
// GenerateKeys generates necessary keys for the app on the given Keychain,
|
||||
|
@ -47,12 +47,6 @@ func (keys *Keychain) GenerateKeys() error {
|
|||
keyErrs = err
|
||||
}
|
||||
}
|
||||
if len(keys.CSRFKey) == 0 {
|
||||
keys.CSRFKey, err = GenerateBytes(EncKeysBytes)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
}
|
||||
|
||||
return keyErrs
|
||||
}
|
||||
|
|
11
keys.go
11
keys.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019, 2021 Musing Studio LLC.
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,7 +12,8 @@ package writefreely
|
|||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"github.com/writeas/writefreely/key"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
@ -25,7 +26,6 @@ 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
|
||||
|
@ -42,7 +42,6 @@ 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)
|
||||
}
|
||||
|
||||
// generateKey generates a key at the given path used for the encryption of
|
||||
|
@ -51,7 +50,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)
|
||||
|
@ -64,7 +63,7 @@ func generateKey(path string) error {
|
|||
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
|
||||
|
|
|
@ -5,7 +5,6 @@ all :
|
|||
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
|
||||
|
||||
install :
|
||||
./install-less.sh
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -5,8 +5,6 @@
|
|||
@import "post-temp";
|
||||
@import "effects";
|
||||
@import "admin";
|
||||
@import "login";
|
||||
@import "pages/error";
|
||||
@import "resources";
|
||||
@import "lib/elements";
|
||||
@import "lib/material";
|
||||
|
|
226
less/core.less
226
less/core.less
|
@ -1,3 +1,15 @@
|
|||
@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;
|
||||
|
@ -5,7 +17,7 @@ body {
|
|||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: white;
|
||||
color: #111;
|
||||
|
||||
|
||||
h1, header h2 {
|
||||
a {
|
||||
color: @headerTextColor;
|
||||
|
@ -69,7 +81,7 @@ body {
|
|||
font-size: 1.5em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.4em;
|
||||
font-size: 1.17em;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -210,10 +222,6 @@ body {
|
|||
pre {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.flash {
|
||||
text-align: center;
|
||||
margin-bottom: 4em;
|
||||
}
|
||||
}
|
||||
&#subpage {
|
||||
#wrapper {
|
||||
|
@ -397,14 +405,6 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
img {
|
||||
&.paid {
|
||||
height: 0.86em;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
nav#full-nav {
|
||||
margin: 0;
|
||||
|
||||
|
@ -524,12 +524,12 @@ pre, body#post article, #post .alert, #subpage .alert, body#collection article,
|
|||
margin-bottom: 1em;
|
||||
p {
|
||||
text-align: left;
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
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 +537,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;
|
||||
}
|
||||
|
@ -639,23 +639,6 @@ 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;
|
||||
|
@ -672,26 +655,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 +682,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 +725,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 +787,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 +812,7 @@ input {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
@ -931,6 +867,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 +964,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 +1006,7 @@ footer.contain-me {
|
|||
}
|
||||
|
||||
li {
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
|
||||
.item-desc, .prog-lang {
|
||||
font-size: 0.6em;
|
||||
|
@ -1093,19 +1038,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 +1053,7 @@ li {
|
|||
}
|
||||
}
|
||||
|
||||
ul.errors {
|
||||
ul.errors {
|
||||
padding: 0;
|
||||
text-indent: 0;
|
||||
li.urgent {
|
||||
|
@ -1162,8 +1094,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 +1244,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 +1252,6 @@ header {
|
|||
}
|
||||
}
|
||||
}
|
||||
.right-side {
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
}
|
||||
.dash-nav {
|
||||
font-weight: bold;
|
||||
|
@ -1389,24 +1317,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 +1326,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 +1392,7 @@ div.row {
|
|||
}
|
||||
|
||||
@media all and (max-width: 600px) {
|
||||
div.row:not(.admin-actions) {
|
||||
div.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.half {
|
||||
|
@ -1577,11 +1477,6 @@ div.row {
|
|||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
article {
|
||||
.hidden {
|
||||
.opacity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
@ -1623,38 +1518,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
&:hover {
|
||||
background: @lightNavHoverBG;
|
||||
}
|
||||
&:hover > ul, &.open > ul {
|
||||
&:hover > ul {
|
||||
display: block;
|
||||
}
|
||||
&.selected {
|
||||
|
@ -256,7 +256,7 @@ body#pad {
|
|||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
textarea, #title {
|
||||
textarea {
|
||||
position: fixed !important;
|
||||
top: 3em;
|
||||
right: 0;
|
||||
|
@ -340,15 +340,6 @@ body#pad {
|
|||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
line-height: 1.5;
|
||||
|
||||
input[type=text].confirm {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.short {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -370,38 +361,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 +420,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) {
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
@import "prose-editor";
|
||||
@import "pad-theme";
|
||||
@import "resources";
|
||||
@import "lib/elements";
|
|
@ -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;
|
181
mailer/mailer.go
181
mailer/mailer.go
|
@ -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
|
||||
}
|
153
main_test.go
153
main_test.go
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -56,22 +56,9 @@ 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)
|
||||
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
|
||||
}
|
||||
|
||||
// CurrentVer returns the current migration version the application is on
|
||||
|
@ -93,9 +80,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
|
||||
|
|
|
@ -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 ,
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 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 {
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,46 +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 migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthAttach(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(
|
||||
"attach_user_id",
|
||||
wf_db.ColumnTypeInteger,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetNullable(true)),
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
|
@ -1,45 +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 migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthInvites(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("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{
|
||||
Set: true,
|
||||
Value: 6,
|
||||
}).SetNullable(true)),
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue