Merge branch 'develop' into support-notes
|
@ -0,0 +1,7 @@
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "gomod" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
open-pull-requests-limit: 50
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
|
@ -1,3 +0,0 @@
|
||||||
[submodule "static/js/mathjax"]
|
|
||||||
path = static/js/mathjax
|
|
||||||
url = https://github.com/mathjax/MathJax.git
|
|
|
@ -1,7 +1,7 @@
|
||||||
language: go
|
language: go
|
||||||
|
|
||||||
go:
|
go:
|
||||||
- "1.11.x"
|
- "1.13.x"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
- GO111MODULE=on
|
- GO111MODULE=on
|
||||||
|
|
101
CONTRIBUTING.md
|
@ -1,26 +1,99 @@
|
||||||
# Contributing to WriteFreely
|
# Contributing to WriteFreely
|
||||||
|
|
||||||
Welcome! We're glad you're interested in contributing to the WriteFreely project.
|
Welcome! We're glad you're interested in contributing to WriteFreely.
|
||||||
|
|
||||||
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 **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
|
||||||
|
|
||||||
## Asking Questions
|
For **bug reports**, please [open a GitHub issue](https://github.com/writeas/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs).
|
||||||
|
|
||||||
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.
|
## Getting Started
|
||||||
|
|
||||||
## Submitting Bugs
|
There are many ways to contribute to WriteFreely, from code to documentation, to translations, to help in the community!
|
||||||
|
|
||||||
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:
|
See our [Contributing Guide](https://writefreely.org/contribute) on WriteFreely.org for ways to contribute without writing code. Otherwise, please read on.
|
||||||
|
|
||||||
* **Only reporting bugs in the issue tracker**
|
## Working on WriteFreely
|
||||||
* 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
|
|
||||||
|
|
||||||
## Contributing code
|
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.
|
||||||
|
|
||||||
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!
|
### Starting development
|
||||||
|
|
||||||
**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/).
|
Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://phabricator.write.as/tag/write_freely/) to see where the project is today and where it's headed.
|
||||||
|
|
||||||
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.
|
When you find something you want to work on, start a new topic on the forum or jump into an existing discussion, if there is one. The team will respond and continue the conversation there.
|
||||||
|
|
||||||
|
Lastly, **before submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
|
||||||
|
|
||||||
|
### 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/writeas/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e)
|
||||||
|
* [Show 404 when remote user not found](https://github.com/writeas/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d)
|
||||||
|
* [Fix post deletion on Pleroma](https://github.com/writeas/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.
|
|
@ -1,5 +1,5 @@
|
||||||
# Build image
|
# Build image
|
||||||
FROM golang:1.12-alpine as build
|
FROM golang:1.13-alpine as build
|
||||||
|
|
||||||
RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev
|
RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev
|
||||||
RUN npm install -g less less-plugin-clean-css
|
RUN npm install -g less less-plugin-clean-css
|
||||||
|
@ -22,7 +22,7 @@ RUN mkdir /stage && \
|
||||||
/stage
|
/stage
|
||||||
|
|
||||||
# Final image
|
# Final image
|
||||||
FROM alpine:3.8
|
FROM alpine:3.11
|
||||||
|
|
||||||
RUN apk add --no-cache openssl ca-certificates
|
RUN apk add --no-cache openssl ca-certificates
|
||||||
COPY --from=build --chown=daemon:daemon /stage /go
|
COPY --from=build --chown=daemon:daemon /stage /go
|
||||||
|
|
31
Makefile
|
@ -25,28 +25,40 @@ build-no-sqlite: assets-no-sqlite deps-no-sqlite
|
||||||
|
|
||||||
build-linux: deps
|
build-linux: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u github.com/karalabe/xgo; \
|
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||||
|
|
||||||
build-windows: deps
|
build-windows: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u github.com/karalabe/xgo; \
|
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||||
|
|
||||||
build-darwin: deps
|
build-darwin: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u github.com/karalabe/xgo; \
|
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||||
|
|
||||||
|
build-arm6: deps
|
||||||
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
|
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||||
|
fi
|
||||||
|
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||||
|
|
||||||
build-arm7: deps
|
build-arm7: deps
|
||||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
$(GOGET) -u github.com/karalabe/xgo; \
|
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||||
fi
|
fi
|
||||||
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||||
|
|
||||||
|
build-arm64: deps
|
||||||
|
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||||
|
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||||
|
fi
|
||||||
|
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||||
|
|
||||||
build-docker :
|
build-docker :
|
||||||
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
||||||
|
|
||||||
|
@ -74,15 +86,24 @@ release : clean ui assets
|
||||||
cp -r templates $(BUILDPATH)
|
cp -r templates $(BUILDPATH)
|
||||||
cp -r pages $(BUILDPATH)
|
cp -r pages $(BUILDPATH)
|
||||||
cp -r static $(BUILDPATH)
|
cp -r static $(BUILDPATH)
|
||||||
|
scripts/invalidate-css.sh $(BUILDPATH)
|
||||||
mkdir $(BUILDPATH)/keys
|
mkdir $(BUILDPATH)/keys
|
||||||
$(MAKE) build-linux
|
$(MAKE) build-linux
|
||||||
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
|
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
|
||||||
rm $(BUILDPATH)/$(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
|
$(MAKE) build-arm7
|
||||||
mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
|
mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
|
||||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
|
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
|
||||||
rm $(BUILDPATH)/$(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
|
$(MAKE) build-darwin
|
||||||
mv build/$(BINARY_NAME)-darwin-10.6-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)
|
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
|
||||||
|
@ -135,7 +156,7 @@ $(TMPBIN)/go-bindata: deps $(TMPBIN)
|
||||||
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
|
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
|
||||||
|
|
||||||
$(TMPBIN)/xgo: deps $(TMPBIN)
|
$(TMPBIN)/xgo: deps $(TMPBIN)
|
||||||
$(GOBUILD) -o $(TMPBIN)/xgo github.com/karalabe/xgo
|
$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
|
||||||
|
|
||||||
ci-assets : $(TMPBIN)/go-bindata
|
ci-assets : $(TMPBIN)/go-bindata
|
||||||
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
||||||
|
|
69
README.md
|
@ -7,81 +7,76 @@
|
||||||
<a href="https://github.com/writeas/writefreely/releases/">
|
<a href="https://github.com/writeas/writefreely/releases/">
|
||||||
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
|
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
|
||||||
</a>
|
</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">
|
<a href="https://travis-ci.org/writeas/writefreely">
|
||||||
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
|
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/writeas/writefreely/releases/latest">
|
<a href="https://github.com/writeas/writefreely/releases/latest">
|
||||||
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
|
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
|
||||||
</a>
|
</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://hub.docker.com/r/writeas/writefreely/">
|
<a href="https://hub.docker.com/r/writeas/writefreely/">
|
||||||
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
|
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath.
|
WriteFreely is free and open source software for building **a writing space** on the web — whether a publication, internal blog, or writing community in the fediverse.
|
||||||
|
|
||||||
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.
|
![](https://writefreely.org/img/screens/pencil-reader.png)
|
||||||
|
|
||||||
[Try the editor](https://write.as/new)
|
[Try the writing experience](https://write.as/new)
|
||||||
|
|
||||||
[Find an instance](https://writefreely.org/instances)
|
[Find an instance](https://writefreely.org/instances)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Start a blog for yourself, or host a community of writers
|
### Made for writing
|
||||||
* 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
|
|
||||||
|
|
||||||
## Hosting
|
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.
|
||||||
|
|
||||||
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.
|
### A connected community
|
||||||
|
|
||||||
### [![Write.as Pro](https://writefreely.org/img/writeas-pro-readme.png)](https://write.as/pro)
|
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.
|
||||||
|
|
||||||
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).
|
### Intuitive organization
|
||||||
|
|
||||||
### [![Write.as for Teams](https://writefreely.org/img/writeas-for-teams-readme.png)](https://write.as/for/teams)
|
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.
|
||||||
|
|
||||||
[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.
|
### 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).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable.
|
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!
|
||||||
|
|
||||||
> **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.
|
For common platforms, start with our [pre-built binaries](https://github.com/writeas/writefreely/releases/) and head over to our [installation guide](https://writefreely.org/start) to get started.
|
||||||
|
|
||||||
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.
|
### Packages
|
||||||
|
|
||||||
## Packages
|
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||||
|
|
||||||
WriteFreely is available in these package repositories:
|
|
||||||
|
|
||||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer/setup).
|
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).
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker).
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
@ -91,4 +86,4 @@ Before contributing anything, please read our [Contributing Guide](https://githu
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Licensed under the AGPL.
|
Copyright © 2018-2020 [A Bunch Tell LLC](https://abunchtell.com) and contributing authors. Licensed under the [AGPL](https://github.com/writeas/writefreely/blob/develop/LICENSE).
|
||||||
|
|
153
account.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -27,6 +27,7 @@ import (
|
||||||
"github.com/writeas/web-core/auth"
|
"github.com/writeas/web-core/auth"
|
||||||
"github.com/writeas/web-core/data"
|
"github.com/writeas/web-core/data"
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
|
|
||||||
"github.com/writeas/writefreely/author"
|
"github.com/writeas/writefreely/author"
|
||||||
"github.com/writeas/writefreely/config"
|
"github.com/writeas/writefreely/config"
|
||||||
"github.com/writeas/writefreely/page"
|
"github.com/writeas/writefreely/page"
|
||||||
|
@ -48,6 +49,7 @@ type (
|
||||||
Separator template.HTML
|
Separator template.HTML
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
CanInvite bool
|
CanInvite bool
|
||||||
|
CollAlias string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -70,7 +72,7 @@ func canUserInvite(cfg *config.Config, isAdmin bool) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (up *UserPage) SetMessaging(u *User) {
|
func (up *UserPage) SetMessaging(u *User) {
|
||||||
//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
// up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -85,6 +87,11 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, 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)
|
reqJSON := IsJSON(r)
|
||||||
|
|
||||||
// Get params
|
// Get params
|
||||||
|
@ -156,17 +163,9 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
||||||
Username: signup.Alias,
|
Username: signup.Alias,
|
||||||
HashedPass: hashedPass,
|
HashedPass: hashedPass,
|
||||||
HasPass: createdWithPass,
|
HasPass: createdWithPass,
|
||||||
Email: zero.NewString("", signup.Email != ""),
|
Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
|
||||||
Created: time.Now().Truncate(time.Second).UTC(),
|
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
|
// Create actual user
|
||||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
|
if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
|
||||||
|
@ -175,11 +174,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
||||||
|
|
||||||
// Log invite if needed
|
// Log invite if needed
|
||||||
if signup.InviteCode != "" {
|
if signup.InviteCode != "" {
|
||||||
cu, err := app.db.GetUserForAuth(signup.Alias)
|
err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -310,16 +305,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
p := &struct {
|
p := &struct {
|
||||||
page.StaticPage
|
page.StaticPage
|
||||||
|
*OAuthButtons
|
||||||
To string
|
To string
|
||||||
Message template.HTML
|
Message template.HTML
|
||||||
Flashes []template.HTML
|
Flashes []template.HTML
|
||||||
LoginUsername string
|
LoginUsername string
|
||||||
}{
|
}{
|
||||||
pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
r.FormValue("to"),
|
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||||
template.HTML(""),
|
To: r.FormValue("to"),
|
||||||
[]template.HTML{},
|
Message: template.HTML(""),
|
||||||
getTempInfo(app, "login-user", r, w),
|
Flashes: []template.HTML{},
|
||||||
|
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||||
}
|
}
|
||||||
|
|
||||||
if earlyError != "" {
|
if earlyError != "" {
|
||||||
|
@ -394,6 +391,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
var err error
|
var err error
|
||||||
var signin userCredentials
|
var signin userCredentials
|
||||||
|
|
||||||
|
if app.cfg.App.DisablePasswordAuth {
|
||||||
|
err := ErrDisabledPasswordAuth
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Log in with one-time token if one is given
|
// Log in with one-time token if one is given
|
||||||
if oneTimeToken != "" {
|
if oneTimeToken != "" {
|
||||||
log.Info("Login: Logging user in via token.")
|
log.Info("Login: Logging user in via token.")
|
||||||
|
@ -492,6 +494,9 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)) {
|
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
|
||||||
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
|
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
|
||||||
}
|
}
|
||||||
|
@ -750,7 +755,7 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
||||||
log.Error("unable to fetch collections: %v", err)
|
log.Error("unable to fetch collections: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view articles: %v", err)
|
log.Error("view articles: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -758,12 +763,12 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
||||||
*UserPage
|
*UserPage
|
||||||
AnonymousPosts *[]PublicPost
|
AnonymousPosts *[]PublicPost
|
||||||
Collections *[]Collection
|
Collections *[]Collection
|
||||||
Suspended bool
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
|
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
|
||||||
AnonymousPosts: p,
|
AnonymousPosts: p,
|
||||||
Collections: c,
|
Collections: c,
|
||||||
Suspended: suspended,
|
Silenced: silenced,
|
||||||
}
|
}
|
||||||
d.UserPage.SetMessaging(u)
|
d.UserPage.SetMessaging(u)
|
||||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
@ -785,7 +790,7 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
|
||||||
uc, _ := app.db.GetUserCollectionCount(u.ID)
|
uc, _ := app.db.GetUserCollectionCount(u.ID)
|
||||||
// TODO: handle any errors
|
// TODO: handle any errors
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view collections %v", err)
|
log.Error("view collections %v", err)
|
||||||
return fmt.Errorf("view collections: %v", err)
|
return fmt.Errorf("view collections: %v", err)
|
||||||
|
@ -797,13 +802,13 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
|
||||||
UsedCollections, TotalCollections int
|
UsedCollections, TotalCollections int
|
||||||
|
|
||||||
NewBlogsDisabled bool
|
NewBlogsDisabled bool
|
||||||
Suspended bool
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
|
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
|
||||||
Collections: c,
|
Collections: c,
|
||||||
UsedCollections: int(uc),
|
UsedCollections: int(uc),
|
||||||
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
|
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
|
||||||
Suspended: suspended,
|
Silenced: silenced,
|
||||||
}
|
}
|
||||||
d.UserPage.SetMessaging(u)
|
d.UserPage.SetMessaging(u)
|
||||||
showUserPage(w, "collections", d)
|
showUserPage(w, "collections", d)
|
||||||
|
@ -821,7 +826,7 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view edit collection %v", err)
|
log.Error("view edit collection %v", err)
|
||||||
return fmt.Errorf("view edit collection: %v", err)
|
return fmt.Errorf("view edit collection: %v", err)
|
||||||
|
@ -830,12 +835,13 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
||||||
obj := struct {
|
obj := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
*Collection
|
*Collection
|
||||||
Suspended bool
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||||
Collection: c,
|
Collection: c,
|
||||||
Suspended: suspended,
|
Silenced: silenced,
|
||||||
}
|
}
|
||||||
|
obj.UserPage.CollAlias = c.Alias
|
||||||
|
|
||||||
showUserPage(w, "collection", obj)
|
showUserPage(w, "collection", obj)
|
||||||
return nil
|
return nil
|
||||||
|
@ -996,7 +1002,7 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
||||||
titleStats = c.DisplayTitle() + " "
|
titleStats = c.DisplayTitle() + " "
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view stats: %v", err)
|
log.Error("view stats: %v", err)
|
||||||
return err
|
return err
|
||||||
|
@ -1007,14 +1013,15 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
||||||
Collection *Collection
|
Collection *Collection
|
||||||
TopPosts *[]PublicPost
|
TopPosts *[]PublicPost
|
||||||
APFollowers int
|
APFollowers int
|
||||||
Suspended bool
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||||
VisitsBlog: alias,
|
VisitsBlog: alias,
|
||||||
Collection: c,
|
Collection: c,
|
||||||
TopPosts: topPosts,
|
TopPosts: topPosts,
|
||||||
Suspended: suspended,
|
Silenced: silenced,
|
||||||
}
|
}
|
||||||
|
obj.UserPage.CollAlias = c.Alias
|
||||||
if app.cfg.App.Federation {
|
if app.cfg.App.Federation {
|
||||||
folls, err := app.db.GetAPFollowers(c)
|
folls, err := app.db.GetAPFollowers(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1042,18 +1049,68 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
||||||
|
|
||||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
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 {
|
obj := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
Email string
|
Email string
|
||||||
HasPass bool
|
HasPass bool
|
||||||
IsLogOut bool
|
IsLogOut bool
|
||||||
Suspended bool
|
Silenced bool
|
||||||
|
OauthSection bool
|
||||||
|
OauthAccounts []oauthAccountInfo
|
||||||
|
OauthSlack bool
|
||||||
|
OauthWriteAs bool
|
||||||
|
OauthGitLab bool
|
||||||
|
GitLabDisplayName string
|
||||||
|
OauthGeneric bool
|
||||||
|
OauthGenericDisplayName string
|
||||||
|
OauthGitea bool
|
||||||
|
GiteaDisplayName string
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||||
Email: fullUser.EmailClear(app.keys),
|
Email: fullUser.EmailClear(app.keys),
|
||||||
HasPass: passIsSet,
|
HasPass: passIsSet,
|
||||||
IsLogOut: r.FormValue("logout") == "1",
|
IsLogOut: r.FormValue("logout") == "1",
|
||||||
Suspended: fullUser.IsSilenced(),
|
Silenced: fullUser.IsSilenced(),
|
||||||
|
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),
|
||||||
}
|
}
|
||||||
|
|
||||||
showUserPage(w, "settings", obj)
|
showUserPage(w, "settings", obj)
|
||||||
|
@ -1097,3 +1154,29 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
|
||||||
// Return value
|
// Return value
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,195 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"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 := ioutil.TempFile("", "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"}
|
||||||
|
}
|
130
activitypub.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -37,6 +37,8 @@ import (
|
||||||
const (
|
const (
|
||||||
// TODO: delete. don't use this!
|
// TODO: delete. don't use this!
|
||||||
apCustomHandleDefault = "blog"
|
apCustomHandleDefault = "blog"
|
||||||
|
|
||||||
|
apCacheTime = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type RemoteUser struct {
|
type RemoteUser struct {
|
||||||
|
@ -44,6 +46,7 @@ type RemoteUser struct {
|
||||||
ActorID string
|
ActorID string
|
||||||
Inbox string
|
Inbox string
|
||||||
SharedInbox string
|
SharedInbox string
|
||||||
|
Handle string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
||||||
|
@ -62,6 +65,12 @@ 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 {
|
func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
w.Header().Set("Server", serverSoftware)
|
w.Header().Set("Server", serverSoftware)
|
||||||
|
|
||||||
|
@ -80,18 +89,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("fetch collection activities: %v", err)
|
log.Error("fetch collection activities: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
|
||||||
p := c.PersonObject()
|
p := c.PersonObject()
|
||||||
|
|
||||||
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, p, http.StatusOK)
|
return impart.RenderActivityJSON(w, p, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,12 +123,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("fetch collection outbox: %v", err)
|
log.Error("fetch collection outbox: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
@ -148,11 +158,13 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
||||||
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
|
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
|
||||||
for _, pp := range *posts {
|
for _, pp := range *posts {
|
||||||
pp.Collection = res
|
pp.Collection = res
|
||||||
o := pp.ActivityObject(app.cfg)
|
o := pp.ActivityObject(app)
|
||||||
a := activitystreams.NewCreateActivity(o)
|
a := activitystreams.NewCreateActivity(o)
|
||||||
|
a.Context = nil
|
||||||
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,12 +186,12 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("fetch collection followers: %v", err)
|
log.Error("fetch collection followers: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
@ -207,6 +219,7 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
|
||||||
ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID)
|
ocp.OrderedItems = append(ocp.OrderedItems, f.ActorID)
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,12 +241,12 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("fetch collection following: %v", err)
|
log.Error("fetch collection following: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
@ -251,6 +264,7 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
|
||||||
// Return outbox page
|
// Return outbox page
|
||||||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p)
|
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p)
|
||||||
ocp.OrderedItems = []interface{}{}
|
ocp.OrderedItems = []interface{}{}
|
||||||
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -270,12 +284,12 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
// TODO: return Reject?
|
// TODO: return Reject?
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("fetch collection inbox: %v", err)
|
log.Error("fetch collection inbox: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
@ -382,6 +396,13 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
if to == nil {
|
||||||
|
if debugging {
|
||||||
|
log.Error("No `to` value!")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
am, err := a.Serialize()
|
am, err := a.Serialize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -390,10 +411,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
||||||
}
|
}
|
||||||
am["@context"] = []string{activitystreams.Namespace}
|
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)
|
err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to make activity POST: %v", err)
|
log.Error("Unable to make activity POST: %v", err)
|
||||||
|
@ -477,7 +494,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
||||||
|
|
||||||
r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
|
r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
|
||||||
r.Header.Add("Content-Type", "application/activity+json")
|
r.Header.Add("Content-Type", "application/activity+json")
|
||||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
h.Write(b)
|
h.Write(b)
|
||||||
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||||
|
@ -502,7 +519,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(r)
|
resp, err := activityPubClient().Do(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -527,7 +544,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
||||||
|
|
||||||
r, _ := http.NewRequest("GET", url, nil)
|
r, _ := http.NewRequest("GET", url, nil)
|
||||||
r.Header.Add("Accept", "application/activity+json")
|
r.Header.Add("Accept", "application/activity+json")
|
||||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||||
|
|
||||||
if debugging {
|
if debugging {
|
||||||
dump, err := httputil.DumpRequestOut(r, true)
|
dump, err := httputil.DumpRequestOut(r, true)
|
||||||
|
@ -538,7 +555,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(r)
|
resp, err := activityPubClient().Do(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -564,7 +581,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
||||||
}
|
}
|
||||||
p.Collection.hostName = app.cfg.App.Host
|
p.Collection.hostName = app.cfg.App.Host
|
||||||
actor := p.Collection.PersonObject(collID)
|
actor := p.Collection.PersonObject(collID)
|
||||||
na := p.ActivityObject(app.cfg)
|
na := p.ActivityObject(app)
|
||||||
|
|
||||||
// Add followers
|
// Add followers
|
||||||
p.Collection.ID = collID
|
p.Collection.ID = collID
|
||||||
|
@ -593,7 +610,12 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
||||||
na.CC = append(na.CC, f)
|
na.CC = append(na.CC, f)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = makeActivityPost(app.cfg.App.Host, actor, si, activitystreams.NewDeleteActivity(na))
|
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"
|
||||||
|
|
||||||
|
err = makeActivityPost(app.cfg.App.Host, actor, si, da)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Couldn't delete post! %v", err)
|
log.Error("Couldn't delete post! %v", err)
|
||||||
}
|
}
|
||||||
|
@ -610,7 +632,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
actor := p.Collection.PersonObject(collID)
|
actor := p.Collection.PersonObject(collID)
|
||||||
na := p.ActivityObject(app.cfg)
|
na := p.ActivityObject(app)
|
||||||
|
|
||||||
// Add followers
|
// Add followers
|
||||||
p.Collection.ID = collID
|
p.Collection.ID = collID
|
||||||
|
@ -628,18 +650,25 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||||
inbox = f.Inbox
|
inbox = f.Inbox
|
||||||
}
|
}
|
||||||
if _, ok := inboxes[inbox]; ok {
|
if _, ok := inboxes[inbox]; ok {
|
||||||
|
// check if we're already sending to this shared inbox
|
||||||
inboxes[inbox] = append(inboxes[inbox], f.ActorID)
|
inboxes[inbox] = append(inboxes[inbox], f.ActorID)
|
||||||
} else {
|
} else {
|
||||||
|
// add the new shared inbox to the list
|
||||||
inboxes[inbox] = []string{f.ActorID}
|
inboxes[inbox] = []string{f.ActorID}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var activity *activitystreams.Activity
|
||||||
|
// for each one of the shared inboxes
|
||||||
for si, instFolls := range inboxes {
|
for si, instFolls := range inboxes {
|
||||||
|
// add all followers from that instance
|
||||||
|
// to the CC field
|
||||||
na.CC = []string{}
|
na.CC = []string{}
|
||||||
for _, f := range instFolls {
|
for _, f := range instFolls {
|
||||||
na.CC = append(na.CC, f)
|
na.CC = append(na.CC, f)
|
||||||
}
|
}
|
||||||
var activity *activitystreams.Activity
|
// create a new "Create" activity
|
||||||
|
// with our article as object
|
||||||
if isUpdate {
|
if isUpdate {
|
||||||
activity = activitystreams.NewUpdateActivity(na)
|
activity = activitystreams.NewUpdateActivity(na)
|
||||||
} else {
|
} else {
|
||||||
|
@ -647,17 +676,47 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||||
activity.To = na.To
|
activity.To = na.To
|
||||||
activity.CC = na.CC
|
activity.CC = na.CC
|
||||||
}
|
}
|
||||||
|
// and post it to that sharedInbox
|
||||||
err = makeActivityPost(app.cfg.App.Host, actor, si, activity)
|
err = makeActivityPost(app.cfg.App.Host, actor, si, activity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Couldn't post! %v", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||||
u := RemoteUser{ActorID: actorID}
|
u := RemoteUser{ActorID: actorID}
|
||||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox)
|
var handle sql.NullString
|
||||||
|
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &handle)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
||||||
|
@ -666,6 +725,23 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox)
|
||||||
|
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
|
||||||
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -743,3 +819,7 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
||||||
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||||
|
}
|
||||||
|
|
123
admin.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -90,6 +90,18 @@ type instanceContent struct {
|
||||||
Updated time.Time
|
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() string {
|
func (c instanceContent) UpdatedFriendly() string {
|
||||||
/*
|
/*
|
||||||
// TODO: accept a locale in this method and use that for the format
|
// TODO: accept a locale in this method and use that for the format
|
||||||
|
@ -100,15 +112,46 @@ func (c instanceContent) UpdatedFriendly() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
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()
|
updateAppStats()
|
||||||
p := struct {
|
p := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
|
*AdminPage
|
||||||
SysStatus systemStatus
|
SysStatus systemStatus
|
||||||
Config config.AppCfg
|
Config config.AppCfg
|
||||||
|
|
||||||
Message, ConfigMessage string
|
Message, ConfigMessage string
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||||
|
AdminPage: NewAdminPage(app),
|
||||||
SysStatus: sysStatus,
|
SysStatus: sysStatus,
|
||||||
Config: app.cfg.App,
|
Config: app.cfg.App,
|
||||||
|
|
||||||
|
@ -116,13 +159,34 @@ func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
||||||
ConfigMessage: r.FormValue("cm"),
|
ConfigMessage: r.FormValue("cm"),
|
||||||
}
|
}
|
||||||
|
|
||||||
showUserPage(w, "admin", p)
|
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)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||||
p := struct {
|
p := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
|
*AdminPage
|
||||||
Config config.AppCfg
|
Config config.AppCfg
|
||||||
Message string
|
Message string
|
||||||
|
|
||||||
|
@ -132,6 +196,7 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
||||||
TotalPages []int
|
TotalPages []int
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Users", nil),
|
UserPage: NewUserPage(app, r, u, "Users", nil),
|
||||||
|
AdminPage: NewAdminPage(app),
|
||||||
Config: app.cfg.App,
|
Config: app.cfg.App,
|
||||||
Message: r.FormValue("m"),
|
Message: r.FormValue("m"),
|
||||||
}
|
}
|
||||||
|
@ -169,6 +234,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
||||||
|
|
||||||
p := struct {
|
p := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
|
*AdminPage
|
||||||
Config config.AppCfg
|
Config config.AppCfg
|
||||||
Message string
|
Message string
|
||||||
|
|
||||||
|
@ -179,6 +245,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
||||||
TotalPosts int64
|
TotalPosts int64
|
||||||
ClearEmail string
|
ClearEmail string
|
||||||
}{
|
}{
|
||||||
|
AdminPage: NewAdminPage(app),
|
||||||
Config: app.cfg.App,
|
Config: app.cfg.App,
|
||||||
Message: r.FormValue("m"),
|
Message: r.FormValue("m"),
|
||||||
Colls: []inspectedCollection{},
|
Colls: []inspectedCollection{},
|
||||||
|
@ -187,7 +254,11 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
||||||
var err error
|
var err error
|
||||||
p.User, err = app.db.GetUserForAuth(username)
|
p.User, err = app.db.GetUserForAuth(username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)}
|
if err == ErrUserNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Error("Could not get user: %v", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||||
|
@ -259,8 +330,8 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht
|
||||||
err = app.db.SetUserStatus(user.ID, UserSilenced)
|
err = app.db.SetUserStatus(user.ID, UserSilenced)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("toggle user suspended: %v", err)
|
log.Error("toggle user silenced: %v", err)
|
||||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v")}
|
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
|
||||||
}
|
}
|
||||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
|
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
|
||||||
}
|
}
|
||||||
|
@ -300,12 +371,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 {
|
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||||
p := struct {
|
p := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
|
*AdminPage
|
||||||
Config config.AppCfg
|
Config config.AppCfg
|
||||||
Message string
|
Message string
|
||||||
|
|
||||||
Pages []*instanceContent
|
Pages []*instanceContent
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
||||||
|
AdminPage: NewAdminPage(app),
|
||||||
Config: app.cfg.App,
|
Config: app.cfg.App,
|
||||||
Message: r.FormValue("m"),
|
Message: r.FormValue("m"),
|
||||||
}
|
}
|
||||||
|
@ -364,12 +437,14 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
||||||
|
|
||||||
p := struct {
|
p := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
|
*AdminPage
|
||||||
Config config.AppCfg
|
Config config.AppCfg
|
||||||
Message string
|
Message string
|
||||||
|
|
||||||
Banner *instanceContent
|
Banner *instanceContent
|
||||||
Content *instanceContent
|
Content *instanceContent
|
||||||
}{
|
}{
|
||||||
|
AdminPage: NewAdminPage(app),
|
||||||
Config: app.cfg.App,
|
Config: app.cfg.App,
|
||||||
Message: r.FormValue("m"),
|
Message: r.FormValue("m"),
|
||||||
}
|
}
|
||||||
|
@ -471,7 +546,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m = "?cm=" + err.Error()
|
m = "?cm=" + err.Error()
|
||||||
}
|
}
|
||||||
return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"}
|
return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateAppStats() {
|
func updateAppStats() {
|
||||||
|
@ -524,3 +599,39 @@ func adminResetPassword(app *App, u *User, newPass string) error {
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
86
app.go
|
@ -30,7 +30,7 @@ import (
|
||||||
"github.com/gorilla/schema"
|
"github.com/gorilla/schema"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
"github.com/manifoldco/promptui"
|
"github.com/manifoldco/promptui"
|
||||||
"github.com/writeas/go-strip-markdown"
|
stripmd "github.com/writeas/go-strip-markdown"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/web-core/auth"
|
"github.com/writeas/web-core/auth"
|
||||||
"github.com/writeas/web-core/converter"
|
"github.com/writeas/web-core/converter"
|
||||||
|
@ -56,7 +56,7 @@ var (
|
||||||
debugging bool
|
debugging bool
|
||||||
|
|
||||||
// Software version can be set from git env using -ldflags
|
// Software version can be set from git env using -ldflags
|
||||||
softwareVer = "0.11.1"
|
softwareVer = "0.12.0"
|
||||||
|
|
||||||
// DEPRECATED VARS
|
// DEPRECATED VARS
|
||||||
isSingleUser bool
|
isSingleUser bool
|
||||||
|
@ -70,8 +70,9 @@ type App struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
cfgFile string
|
cfgFile string
|
||||||
keys *key.Keychain
|
keys *key.Keychain
|
||||||
sessionStore *sessions.CookieStore
|
sessionStore sessions.Store
|
||||||
formDecoder *schema.Decoder
|
formDecoder *schema.Decoder
|
||||||
|
updates *updatesCache
|
||||||
|
|
||||||
timeline *localTimeline
|
timeline *localTimeline
|
||||||
}
|
}
|
||||||
|
@ -101,6 +102,14 @@ func (app *App) SetKeys(k *key.Keychain) {
|
||||||
app.keys = k
|
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
|
// Apper is the interface for getting data into and out of a WriteFreely
|
||||||
// instance (or "App").
|
// instance (or "App").
|
||||||
//
|
//
|
||||||
|
@ -212,6 +221,10 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
return handleViewPad(app, w, r)
|
return handleViewPad(app, w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if app.cfg.App.Private {
|
||||||
|
return viewLogin(app, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
if land := app.cfg.App.LandingPath(); land != "/" {
|
if land := app.cfg.App.LandingPath(); land != "/" {
|
||||||
return impart.HTTPError{http.StatusFound, land}
|
return impart.HTTPError{http.StatusFound, land}
|
||||||
}
|
}
|
||||||
|
@ -225,6 +238,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
p := struct {
|
p := struct {
|
||||||
page.StaticPage
|
page.StaticPage
|
||||||
|
*OAuthButtons
|
||||||
Flashes []template.HTML
|
Flashes []template.HTML
|
||||||
Banner template.HTML
|
Banner template.HTML
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
|
@ -232,6 +246,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
ForcedLanding bool
|
ForcedLanding bool
|
||||||
}{
|
}{
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
|
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||||
ForcedLanding: forceLanding,
|
ForcedLanding: forceLanding,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -363,6 +378,8 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init keys: %s", err)
|
return nil, fmt.Errorf("init keys: %s", err)
|
||||||
}
|
}
|
||||||
|
apper.App().InitUpdates()
|
||||||
|
|
||||||
apper.App().InitSession()
|
apper.App().InitSession()
|
||||||
|
|
||||||
apper.App().InitDecoder()
|
apper.App().InitDecoder()
|
||||||
|
@ -398,6 +415,11 @@ func Serve(app *App, r *mux.Router) {
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Start gopher server
|
||||||
|
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
|
||||||
|
go initGopher(app)
|
||||||
|
}
|
||||||
|
|
||||||
// Start web application server
|
// Start web application server
|
||||||
var bindAddress = app.cfg.Server.Bind
|
var bindAddress = app.cfg.Server.Bind
|
||||||
if bindAddress == "" {
|
if bindAddress == "" {
|
||||||
|
@ -681,13 +703,59 @@ func ResetPassword(apper Apper, username string) error {
|
||||||
return nil
|
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) {
|
func connectToDatabase(app *App) {
|
||||||
log.Info("Connecting to %s database...", app.cfg.Database.Type)
|
log.Info("Connecting to %s database...", app.cfg.Database.Type)
|
||||||
|
|
||||||
var db *sql.DB
|
var db *sql.DB
|
||||||
var err error
|
var err error
|
||||||
if app.cfg.Database.Type == driverMySQL {
|
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", 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, 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.SetMaxOpenConns(50)
|
db.SetMaxOpenConns(50)
|
||||||
} else if app.cfg.Database.Type == driverSQLite {
|
} else if app.cfg.Database.Type == driverSQLite {
|
||||||
if !SQLiteEnabled {
|
if !SQLiteEnabled {
|
||||||
|
@ -824,3 +892,13 @@ func adminInitDatabase(app *App) error {
|
||||||
log.Info("Done.")
|
log.Info("Done.")
|
||||||
return nil
|
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 + ")"
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -65,6 +65,7 @@ var reservedUsernames = map[string]bool{
|
||||||
"metadata": true,
|
"metadata": true,
|
||||||
"new": true,
|
"new": true,
|
||||||
"news": true,
|
"news": true,
|
||||||
|
"oauth": true,
|
||||||
"post": true,
|
"post": true,
|
||||||
"posts": true,
|
"posts": true,
|
||||||
"privacy": true,
|
"privacy": true,
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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/writeas/writefreely"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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/writeas/writefreely"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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/writeas/writefreely"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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-2019 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -11,113 +11,157 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/writeas/web-core/log"
|
|
||||||
"github.com/writeas/writefreely"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
"github.com/writeas/writefreely"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// General options usable with other commands
|
cli.VersionPrinter = func(c *cli.Context) {
|
||||||
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
|
fmt.Printf("%s\n", c.App.Version)
|
||||||
configFile := flag.String("c", "config.ini", "The configuration file to use")
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// Setup actions
|
defaultFlags := []cli.Flag{
|
||||||
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
|
&cli.StringFlag{
|
||||||
doConfig := flag.Bool("config", false, "Run the configuration process")
|
Name: "c",
|
||||||
configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+
|
Value: "config.ini",
|
||||||
"valid values are any combination of 'server', 'db' and 'app' "+
|
Usage: "Load configuration from `FILE`",
|
||||||
"example: writefreely --config --sections \"db app\"")
|
},
|
||||||
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
|
&cli.BoolFlag{
|
||||||
createSchema := flag.Bool("init-db", false, "Initialize app database")
|
Name: "debug",
|
||||||
migrate := flag.Bool("migrate", false, "Migrate the database")
|
Value: false,
|
||||||
|
Usage: "Enables debug logging",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// Admin actions
|
app.Flags = append(app.Flags, defaultFlags...)
|
||||||
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 := writefreely.NewApp(*configFile)
|
app.Commands = []*cli.Command{
|
||||||
|
&cmdUser,
|
||||||
|
&cmdDB,
|
||||||
|
&cmdConfig,
|
||||||
|
&cmdKeys,
|
||||||
|
&cmdServe,
|
||||||
|
}
|
||||||
|
|
||||||
if *outputVersion {
|
err := app.Run(os.Args)
|
||||||
writefreely.OutputVersion()
|
|
||||||
os.Exit(0)
|
|
||||||
} else if *createConfig {
|
|
||||||
err := writefreely.CreateConfig(app)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
log.Error(err.Error())
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
}
|
||||||
} else if *doConfig {
|
|
||||||
writefreely.DoConfig(app, *configSections)
|
func legacyActions(c *cli.Context) error {
|
||||||
os.Exit(0)
|
app := writefreely.NewApp(c.String("c"))
|
||||||
} else if *genKeys {
|
|
||||||
err := writefreely.GenerateKeyFiles(app)
|
switch true {
|
||||||
|
case c.IsSet("create-config"):
|
||||||
|
return writefreely.CreateConfig(app)
|
||||||
|
case c.IsSet("config"):
|
||||||
|
writefreely.DoConfig(app, c.String("sections"))
|
||||||
|
return nil
|
||||||
|
case c.IsSet("gen-keys"):
|
||||||
|
return writefreely.GenerateKeyFiles(app)
|
||||||
|
case c.IsSet("init-db"):
|
||||||
|
return writefreely.CreateSchema(app)
|
||||||
|
case c.IsSet("migrate"):
|
||||||
|
return writefreely.Migrate(app)
|
||||||
|
case c.IsSet("create-admin"):
|
||||||
|
username, password, err := parseCredentials(c.String("create-admin"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
return writefreely.CreateUser(app, username, password, true)
|
||||||
} else if *createSchema {
|
case c.IsSet("create-user"):
|
||||||
err := writefreely.CreateSchema(app)
|
username, password, err := parseCredentials(c.String("create-user"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err.Error())
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
return writefreely.CreateUser(app, username, password, false)
|
||||||
} else if *createAdmin != "" {
|
case c.IsSet("delete-user"):
|
||||||
username, password, err := userPass(*createAdmin, true)
|
return writefreely.DoDeleteAccount(app, c.String("delete-user"))
|
||||||
if err != nil {
|
case c.IsSet("reset-pass"):
|
||||||
log.Error(err.Error())
|
return writefreely.ResetPassword(app, c.String("reset-pass"))
|
||||||
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
|
// Initialize the application
|
||||||
var err error
|
var err error
|
||||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||||
app, err = writefreely.Initialize(app, *debugPtr)
|
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("%s", err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set app routes
|
// Set app routes
|
||||||
|
@ -127,20 +171,14 @@ func main() {
|
||||||
|
|
||||||
// Serve the application
|
// Serve the application
|
||||||
writefreely.Serve(app, r)
|
writefreely.Serve(app, r)
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func userPass(credStr string, isAdmin bool) (user string, pass string, err error) {
|
func parseCredentials(credentialString string) (string, string, error) {
|
||||||
creds := strings.Split(credStr, ":")
|
creds := strings.Split(credentialString, ":")
|
||||||
if len(creds) != 2 {
|
if len(creds) != 2 {
|
||||||
c := "user"
|
return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
|
||||||
if isAdmin {
|
|
||||||
c = "admin"
|
|
||||||
}
|
}
|
||||||
err = fmt.Errorf("usage: writefreely --create-%s username:password", c)
|
return creds[0], creds[1], nil
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user = creds[0]
|
|
||||||
pass = creds[1]
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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/writeas/writefreely"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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/writeas/web-core/log"
|
||||||
|
"github.com/writeas/writefreely"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -47,6 +47,7 @@ type (
|
||||||
Language string `schema:"lang" json:"lang,omitempty"`
|
Language string `schema:"lang" json:"lang,omitempty"`
|
||||||
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
|
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
|
||||||
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
|
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
|
||||||
|
Signature string `datastore:"post_signature" schema:"signature" json:"-"`
|
||||||
Public bool `datastore:"public" json:"public"`
|
Public bool `datastore:"public" json:"public"`
|
||||||
Visibility collVisibility `datastore:"private" json:"-"`
|
Visibility collVisibility `datastore:"private" json:"-"`
|
||||||
Format string `datastore:"format" json:"format,omitempty"`
|
Format string `datastore:"format" json:"format,omitempty"`
|
||||||
|
@ -63,6 +64,7 @@ type (
|
||||||
TotalPosts int `json:"total_posts"`
|
TotalPosts int `json:"total_posts"`
|
||||||
Owner *User `json:"owner,omitempty"`
|
Owner *User `json:"owner,omitempty"`
|
||||||
Posts *[]PublicPost `json:"posts,omitempty"`
|
Posts *[]PublicPost `json:"posts,omitempty"`
|
||||||
|
Format *CollectionFormat
|
||||||
}
|
}
|
||||||
DisplayCollection struct {
|
DisplayCollection struct {
|
||||||
*CollectionObj
|
*CollectionObj
|
||||||
|
@ -70,8 +72,7 @@ type (
|
||||||
IsTopLevel bool
|
IsTopLevel bool
|
||||||
CurrentPage int
|
CurrentPage int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
Format *CollectionFormat
|
Silenced bool
|
||||||
Suspended bool
|
|
||||||
}
|
}
|
||||||
SubmittedCollection struct {
|
SubmittedCollection struct {
|
||||||
// Data used for updating a given collection
|
// Data used for updating a given collection
|
||||||
|
@ -91,6 +92,7 @@ type (
|
||||||
Description *string `schema:"description" json:"description"`
|
Description *string `schema:"description" json:"description"`
|
||||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
|
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
|
||||||
Script *sql.NullString `schema:"script" json:"script"`
|
Script *sql.NullString `schema:"script" json:"script"`
|
||||||
|
Signature *sql.NullString `schema:"signature" json:"signature"`
|
||||||
Visibility *int `schema:"visibility" json:"public"`
|
Visibility *int `schema:"visibility" json:"public"`
|
||||||
Format *sql.NullString `schema:"format" json:"format"`
|
Format *sql.NullString `schema:"format" json:"format"`
|
||||||
}
|
}
|
||||||
|
@ -397,13 +399,13 @@ func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
userID = u.ID
|
userID = u.ID
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(userID)
|
silenced, err := app.db.IsUserSilenced(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("new collection: %v", err)
|
log.Error("new collection: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrUserSuspended
|
return ErrUserSilenced
|
||||||
}
|
}
|
||||||
|
|
||||||
if !author.IsValidUsername(app.cfg, c.Alias) {
|
if !author.IsValidUsername(app.cfg, c.Alias) {
|
||||||
|
@ -487,7 +489,7 @@ func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
res.Owner = u
|
res.Owner = u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO: check suspended
|
// TODO: check status for silenced
|
||||||
app.db.GetPostsCount(res, isCollOwner)
|
app.db.GetPostsCount(res, isCollOwner)
|
||||||
// Strip non-public information
|
// Strip non-public information
|
||||||
res.Collection.ForPublic()
|
res.Collection.ForPublic()
|
||||||
|
@ -556,6 +558,13 @@ type CollectionPage struct {
|
||||||
CanInvite bool
|
CanInvite bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewCollectionObj(c *Collection) *CollectionObj {
|
||||||
|
return &CollectionObj{
|
||||||
|
Collection: *c,
|
||||||
|
Format: c.NewFormat(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CollectionObj) ScriptDisplay() template.JS {
|
func (c *CollectionObj) ScriptDisplay() template.JS {
|
||||||
return template.JS(c.Script)
|
return template.JS(c.Script)
|
||||||
}
|
}
|
||||||
|
@ -648,6 +657,16 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
|
||||||
uname = u.Username
|
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
|
// See if we've authorized this collection
|
||||||
authd := isAuthorizedForCollection(app, c.Alias, r)
|
authd := isAuthorizedForCollection(app, c.Alias, r)
|
||||||
|
|
||||||
|
@ -695,11 +714,10 @@ func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPost
|
||||||
|
|
||||||
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
|
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
|
||||||
coll := &DisplayCollection{
|
coll := &DisplayCollection{
|
||||||
CollectionObj: &CollectionObj{Collection: *c},
|
CollectionObj: NewCollectionObj(c),
|
||||||
CurrentPage: page,
|
CurrentPage: page,
|
||||||
Prefix: cr.prefix,
|
Prefix: cr.prefix,
|
||||||
IsTopLevel: isSingleUser,
|
IsTopLevel: isSingleUser,
|
||||||
Format: c.NewFormat(),
|
|
||||||
}
|
}
|
||||||
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||||
return coll
|
return coll
|
||||||
|
@ -738,7 +756,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view collection: %v", err)
|
log.Error("view collection: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
|
@ -748,6 +766,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||||
ac := c.PersonObject()
|
ac := c.PersonObject()
|
||||||
ac.Context = []interface{}{activitystreams.Namespace}
|
ac.Context = []interface{}{activitystreams.Namespace}
|
||||||
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -800,10 +819,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
log.Error("Error getting user for collection: %v", err)
|
log.Error("Error getting user for collection: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isOwner && suspended {
|
if !isOwner && silenced {
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
displayPage.Suspended = isOwner && suspended
|
displayPage.Silenced = isOwner && silenced
|
||||||
displayPage.Owner = owner
|
displayPage.Owner = owner
|
||||||
coll.Owner = displayPage.Owner
|
coll.Owner = displayPage.Owner
|
||||||
|
|
||||||
|
@ -840,6 +859,19 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
handle := vars["handle"]
|
||||||
|
|
||||||
|
remoteUser, err := app.db.GetProfilePageFromHandle(app, handle)
|
||||||
|
if err != nil || remoteUser == "" {
|
||||||
|
log.Error("Couldn't find user %s: %v", handle, err)
|
||||||
|
return ErrRemoteUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return impart.HTTPError{Status: http.StatusFound, Message: remoteUser}
|
||||||
|
}
|
||||||
|
|
||||||
func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
|
func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
tag := vars["tag"]
|
tag := vars["tag"]
|
||||||
|
@ -909,7 +941,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
displayPage.Suspended = owner != nil && owner.IsSilenced()
|
displayPage.Silenced = owner != nil && owner.IsSilenced()
|
||||||
displayPage.Owner = owner
|
displayPage.Owner = owner
|
||||||
coll.Owner = displayPage.Owner
|
coll.Owner = displayPage.Owner
|
||||||
// Add more data
|
// Add more data
|
||||||
|
@ -963,14 +995,14 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("existing collection: %v", err)
|
log.Error("existing collection: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
|
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrUserSuspended
|
return ErrUserSilenced
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "DELETE" {
|
if r.Method == "DELETE" {
|
||||||
|
|
|
@ -9,6 +9,7 @@ password = changeme
|
||||||
database = writefreely
|
database = writefreely
|
||||||
host = db
|
host = db
|
||||||
port = 3306
|
port = 3306
|
||||||
|
tls = false
|
||||||
|
|
||||||
[app]
|
[app]
|
||||||
site_name = WriteFreely Example Blog!
|
site_name = WriteFreely Example Blog!
|
||||||
|
@ -23,4 +24,5 @@ max_blogs = 1
|
||||||
federation = true
|
federation = true
|
||||||
public_stats = true
|
public_stats = true
|
||||||
private = false
|
private = false
|
||||||
|
update_checks = true
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,9 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gopkg.in/ini.v1"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/ini.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -42,6 +43,10 @@ type (
|
||||||
PagesParentDir string `ini:"pages_parent_dir"`
|
PagesParentDir string `ini:"pages_parent_dir"`
|
||||||
KeysParentDir string `ini:"keys_parent_dir"`
|
KeysParentDir string `ini:"keys_parent_dir"`
|
||||||
|
|
||||||
|
HashSeed string `ini:"hash_seed"`
|
||||||
|
|
||||||
|
GopherPort int `ini:"gopher_port"`
|
||||||
|
|
||||||
Dev bool `ini:"-"`
|
Dev bool `ini:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,6 +59,56 @@ type (
|
||||||
Database string `ini:"database"`
|
Database string `ini:"database"`
|
||||||
Host string `ini:"host"`
|
Host string `ini:"host"`
|
||||||
Port int `ini:"port"`
|
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"`
|
||||||
|
AllowDisconnect bool `ini:"allow_disconnect"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppCfg holds values that affect how the application functions
|
// AppCfg holds values that affect how the application functions
|
||||||
|
@ -73,6 +128,7 @@ type (
|
||||||
|
|
||||||
// Site functionality
|
// Site functionality
|
||||||
Chorus bool `ini:"chorus"`
|
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"`
|
DisableDrafts bool `ini:"disable_drafts"`
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
@ -94,6 +150,12 @@ type (
|
||||||
|
|
||||||
// Defaults
|
// Defaults
|
||||||
DefaultVisibility string `ini:"default_visibility"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds the complete configuration for running a writefreely instance
|
// Config holds the complete configuration for running a writefreely instance
|
||||||
|
@ -101,6 +163,11 @@ type (
|
||||||
Server ServerCfg `ini:"server"`
|
Server ServerCfg `ini:"server"`
|
||||||
Database DatabaseCfg `ini:"database"`
|
Database DatabaseCfg `ini:"database"`
|
||||||
App AppCfg `ini:"app"`
|
App AppCfg `ini:"app"`
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -156,6 +223,16 @@ func (ac *AppCfg) LandingPath() string {
|
||||||
return ac.Landing
|
return ac.Landing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
// Load reads the given configuration file, then parses and returns it as a Config.
|
||||||
func Load(fname string) (*Config, error) {
|
func Load(fname string) (*Config, error) {
|
||||||
if fname == "" {
|
if fname == "" {
|
||||||
|
|
|
@ -11,7 +11,9 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FriendlyHost returns the app's Host sans any schema
|
// FriendlyHost returns the app's Host sans any schema
|
||||||
|
@ -25,3 +27,16 @@ func (ac AppCfg) CanCreateBlogs(currentlyUsed uint64) bool {
|
||||||
}
|
}
|
||||||
return int(currentlyUsed) < ac.MaxBlogs
|
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}
|
||||||
|
}
|
||||||
|
|
|
@ -356,7 +356,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
||||||
if data.Config.App.Federation {
|
if data.Config.App.Federation {
|
||||||
selPrompt = promptui.Select{
|
selPrompt = promptui.Select{
|
||||||
Templates: selTmpls,
|
Templates: selTmpls,
|
||||||
Label: "Federation usage stats",
|
Label: "Usage stats (active users, posts)",
|
||||||
Items: []string{"Public", "Private"},
|
Items: []string{"Public", "Private"},
|
||||||
}
|
}
|
||||||
_, fedStatsType, err := selPrompt.Run()
|
_, fedStatsType, err := selPrompt.Run()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// +build wflib
|
// +build wflib
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright © 2019 A Bunch Tell LLC.
|
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -18,3 +18,11 @@ package writefreely
|
||||||
func (db *datastore) isDuplicateKeyErr(err error) bool {
|
func (db *datastore) isDuplicateKeyErr(err error) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) isIgnorableError(err error) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) isHighLoadError(err error) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// +build !sqlite,!wflib
|
// +build !sqlite,!wflib
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright © 2019 A Bunch Tell LLC.
|
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -28,3 +28,25 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
|
||||||
|
|
||||||
return false
|
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,7 +1,7 @@
|
||||||
// +build sqlite,!wflib
|
// +build sqlite,!wflib
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright © 2019 A Bunch Tell LLC.
|
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -48,3 +48,25 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
|
||||||
|
|
||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|
372
database.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -11,8 +11,11 @@
|
||||||
package writefreely
|
package writefreely
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/writeas/web-core/silobridge"
|
||||||
|
wf_db "github.com/writeas/writefreely/db"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -20,6 +23,7 @@ import (
|
||||||
"github.com/guregu/null"
|
"github.com/guregu/null"
|
||||||
"github.com/guregu/null/zero"
|
"github.com/guregu/null/zero"
|
||||||
uuid "github.com/nu7hatch/gouuid"
|
uuid "github.com/nu7hatch/gouuid"
|
||||||
|
"github.com/writeas/activityserve"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/nerds/store"
|
"github.com/writeas/nerds/store"
|
||||||
"github.com/writeas/web-core/activitypub"
|
"github.com/writeas/web-core/activitypub"
|
||||||
|
@ -35,6 +39,9 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
mySQLErrDuplicateKey = 1062
|
mySQLErrDuplicateKey = 1062
|
||||||
|
mySQLErrCollationMix = 1267
|
||||||
|
mySQLErrTooManyConns = 1040
|
||||||
|
mySQLErrMaxUserConns = 1203
|
||||||
|
|
||||||
driverMySQL = "mysql"
|
driverMySQL = "mysql"
|
||||||
driverSQLite = "sqlite3"
|
driverSQLite = "sqlite3"
|
||||||
|
@ -61,7 +68,7 @@ type writestore interface {
|
||||||
GetAccessToken(userID int64) (string, error)
|
GetAccessToken(userID int64) (string, error)
|
||||||
GetTemporaryAccessToken(userID int64, validSecs int) (string, error)
|
GetTemporaryAccessToken(userID int64, validSecs int) (string, error)
|
||||||
GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error)
|
GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error)
|
||||||
DeleteAccount(userID int64) (l *string, err error)
|
DeleteAccount(userID int64) error
|
||||||
ChangeSettings(app *App, u *User, s *userSettings) error
|
ChangeSettings(app *App, u *User, s *userSettings) error
|
||||||
ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error
|
ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error
|
||||||
|
|
||||||
|
@ -124,6 +131,13 @@ type writestore interface {
|
||||||
GetUserLastPostTime(id int64) (*time.Time, error)
|
GetUserLastPostTime(id int64) (*time.Time, error)
|
||||||
GetCollectionLastPostTime(id int64) (*time.Time, error)
|
GetCollectionLastPostTime(id int64) (*time.Time, error)
|
||||||
|
|
||||||
|
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||||
|
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||||
|
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||||
|
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||||
|
GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error)
|
||||||
|
RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error
|
||||||
|
|
||||||
DatabaseInitialized() bool
|
DatabaseInitialized() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,6 +146,8 @@ type datastore struct {
|
||||||
driverName string
|
driverName string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ writestore = &datastore{}
|
||||||
|
|
||||||
func (db *datastore) now() string {
|
func (db *datastore) now() string {
|
||||||
if db.driverName == driverSQLite {
|
if db.driverName == driverSQLite {
|
||||||
return "strftime('%Y-%m-%d %H:%M:%S','now')"
|
return "strftime('%Y-%m-%d %H:%M:%S','now')"
|
||||||
|
@ -163,6 +179,7 @@ func (db *datastore) dateSub(l int, unit string) string {
|
||||||
return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit)
|
return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user in the database from the given User, UPDATING it in the process with the user's ID.
|
||||||
func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error {
|
func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error {
|
||||||
if db.PostIDExists(u.Username) {
|
if db.PostIDExists(u.Username) {
|
||||||
return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
|
return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
|
||||||
|
@ -308,18 +325,18 @@ func (db *datastore) GetUserByID(id int64) (*User, error) {
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUserSuspended returns true if the user account associated with id is
|
// IsUserSilenced returns true if the user account associated with id is
|
||||||
// currently suspended.
|
// currently silenced.
|
||||||
func (db *datastore) IsUserSuspended(id int64) (bool, error) {
|
func (db *datastore) IsUserSilenced(id int64) (bool, error) {
|
||||||
u := &User{ID: id}
|
u := &User{ID: id}
|
||||||
|
|
||||||
err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
|
err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return false, fmt.Errorf("is user suspended: %v", ErrUserNotFound)
|
return false, fmt.Errorf("is user silenced: %v", ErrUserNotFound)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Error("Couldn't SELECT user password: %v", err)
|
log.Error("Couldn't SELECT user status: %v", err)
|
||||||
return false, fmt.Errorf("is user suspended: %v", err)
|
return false, fmt.Errorf("is user silenced: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return u.IsSilenced(), nil
|
return u.IsSilenced(), nil
|
||||||
|
@ -775,19 +792,22 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll
|
||||||
c := &Collection{}
|
c := &Collection{}
|
||||||
|
|
||||||
// FIXME: change Collection to reflect database values. Add helper functions to get actual values
|
// FIXME: change Collection to reflect database values. Add helper functions to get actual values
|
||||||
var styleSheet, script, format zero.String
|
var styleSheet, script, signature, format zero.String
|
||||||
row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value)
|
row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, post_signature, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value)
|
||||||
|
|
||||||
err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &c.Views)
|
err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &signature, &format, &c.OwnerID, &c.Visibility, &c.Views)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||||
|
case db.isHighLoadError(err):
|
||||||
|
return nil, ErrUnavailable
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Error("Failed selecting from collections: %v", err)
|
log.Error("Failed selecting from collections: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.StyleSheet = styleSheet.String
|
c.StyleSheet = styleSheet.String
|
||||||
c.Script = script.String
|
c.Script = script.String
|
||||||
|
c.Signature = signature.String
|
||||||
c.Format = format.String
|
c.Format = format.String
|
||||||
c.Public = c.IsPublic()
|
c.Public = c.IsPublic()
|
||||||
|
|
||||||
|
@ -831,7 +851,8 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro
|
||||||
SetStringPtr(c.Title, "title").
|
SetStringPtr(c.Title, "title").
|
||||||
SetStringPtr(c.Description, "description").
|
SetStringPtr(c.Description, "description").
|
||||||
SetNullString(c.StyleSheet, "style_sheet").
|
SetNullString(c.StyleSheet, "style_sheet").
|
||||||
SetNullString(c.Script, "script")
|
SetNullString(c.Script, "script").
|
||||||
|
SetNullString(c.Signature, "post_signature")
|
||||||
|
|
||||||
if c.Format != nil {
|
if c.Format != nil {
|
||||||
cf := &CollectionFormat{Format: c.Format.String}
|
cf := &CollectionFormat{Format: c.Format.String}
|
||||||
|
@ -1132,6 +1153,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
p.extractData()
|
p.extractData()
|
||||||
|
p.augmentContent(c)
|
||||||
p.formatContent(cfg, c, includeFuture)
|
p.formatContent(cfg, c, includeFuture)
|
||||||
|
|
||||||
posts = append(posts, p.processPost())
|
posts = append(posts, p.processPost())
|
||||||
|
@ -1196,6 +1218,7 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
p.extractData()
|
p.extractData()
|
||||||
|
p.augmentContent(c)
|
||||||
p.formatContent(cfg, c, includeFuture)
|
p.formatContent(cfg, c, includeFuture)
|
||||||
|
|
||||||
posts = append(posts, p.processPost())
|
posts = append(posts, p.processPost())
|
||||||
|
@ -1572,6 +1595,7 @@ func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
p.extractData()
|
p.extractData()
|
||||||
|
p.augmentContent(&coll.Collection)
|
||||||
|
|
||||||
pp := p.processPost()
|
pp := p.processPost()
|
||||||
pp.Collection = coll
|
pp.Collection = coll
|
||||||
|
@ -1622,6 +1646,40 @@ func (db *datastore) GetPublishableCollections(u *User, hostName string) (*[]Col
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetPublicCollections(hostName string) (*[]Collection, error) {
|
||||||
|
rows, err := db.Query(`SELECT c.id, alias, title, description, privacy, view_count
|
||||||
|
FROM collections c
|
||||||
|
LEFT JOIN users u ON u.id = c.owner_id
|
||||||
|
WHERE c.privacy = 1 AND u.status = 0
|
||||||
|
ORDER BY id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed selecting public collections: %v", err)
|
||||||
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve public collections."}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
colls := []Collection{}
|
||||||
|
for rows.Next() {
|
||||||
|
c := Collection{}
|
||||||
|
err = rows.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility, &c.Views)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed scanning row: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
c.hostName = hostName
|
||||||
|
c.URL = c.CanonicalURL()
|
||||||
|
c.Public = c.IsPublic()
|
||||||
|
|
||||||
|
colls = append(colls, c)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Error after Next() on rows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &colls, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) GetMeStats(u *User) userMeStats {
|
func (db *datastore) GetMeStats(u *User) userMeStats {
|
||||||
s := userMeStats{}
|
s := userMeStats{}
|
||||||
|
|
||||||
|
@ -2005,7 +2063,7 @@ func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error {
|
||||||
func (db *datastore) GetCollectionRedirect(alias string) (new string) {
|
func (db *datastore) GetCollectionRedirect(alias string) (new string) {
|
||||||
row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias)
|
row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias)
|
||||||
err := row.Scan(&new)
|
err := row.Scan(&new)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows && !db.isIgnorableError(err) {
|
||||||
log.Error("Failed selecting from collectionredirects: %v", err)
|
log.Error("Failed selecting from collectionredirects: %v", err)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -2104,22 +2162,13 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *datastore) DeleteAccount(userID int64) (l *string, err error) {
|
// DeleteAccount will delete the entire account for userID
|
||||||
debug := ""
|
func (db *datastore) DeleteAccount(userID int64) error {
|
||||||
l = &debug
|
|
||||||
|
|
||||||
t, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
stringLogln(l, "Unable to begin: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all collections
|
// Get all collections
|
||||||
rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID)
|
rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
log.Error("Unable to get collections: %v", err)
|
||||||
stringLogln(l, "Unable to get collections: %v", err)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
colls := []Collection{}
|
colls := []Collection{}
|
||||||
|
@ -2127,103 +2176,158 @@ func (db *datastore) DeleteAccount(userID int64) (l *string, err error) {
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
err = rows.Scan(&c.ID, &c.Alias)
|
err = rows.Scan(&c.ID, &c.Alias)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
log.Error("Unable to scan collection cols: %v", err)
|
||||||
stringLogln(l, "Unable to scan collection cols: %v", err)
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
colls = append(colls, c)
|
colls = append(colls, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to begin: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up all collection related information
|
||||||
var res sql.Result
|
var res sql.Result
|
||||||
for _, c := range colls {
|
for _, c := range colls {
|
||||||
// TODO: user deleteCollection() func
|
|
||||||
// Delete tokens
|
// Delete tokens
|
||||||
res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID)
|
res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err)
|
log.Error("Unable to delete attributes on %s: %v", c.Alias, err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ := res.RowsAffected()
|
rs, _ := res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d for %s from collectionattributes", rs, c.Alias)
|
log.Info("Deleted %d for %s from collectionattributes", rs, c.Alias)
|
||||||
|
|
||||||
// Remove any optional collection password
|
// Remove any optional collection password
|
||||||
res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
|
res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err)
|
log.Error("Unable to delete passwords on %s: %v", c.Alias, err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ = res.RowsAffected()
|
rs, _ = res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d for %s from collectionpasswords", rs, c.Alias)
|
log.Info("Deleted %d for %s from collectionpasswords", rs, c.Alias)
|
||||||
|
|
||||||
// Remove redirects to this collection
|
// Remove redirects to this collection
|
||||||
res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias)
|
res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err)
|
log.Error("Unable to delete redirects on %s: %v", c.Alias, err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ = res.RowsAffected()
|
rs, _ = res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d for %s from collectionredirects", rs, c.Alias)
|
log.Info("Deleted %d for %s from collectionredirects", rs, c.Alias)
|
||||||
|
|
||||||
|
// Remove any collection keys
|
||||||
|
res, err = t.Exec("DELETE FROM collectionkeys WHERE collection_id = ?", c.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Unable to delete keys on %s: %v", c.Alias, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rs, _ = res.RowsAffected()
|
||||||
|
log.Info("Deleted %d for %s from collectionkeys", rs, c.Alias)
|
||||||
|
|
||||||
|
// TODO: federate delete collection
|
||||||
|
|
||||||
|
// Remove remote follows
|
||||||
|
res, err = t.Exec("DELETE FROM remotefollows WHERE collection_id = ?", c.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Unable to delete remote follows on %s: %v", c.Alias, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rs, _ = res.RowsAffected()
|
||||||
|
log.Info("Deleted %d for %s from remotefollows", rs, c.Alias)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete collections
|
// Delete collections
|
||||||
res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID)
|
res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete collections: %v", err)
|
log.Error("Unable to delete collections: %v", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ := res.RowsAffected()
|
rs, _ := res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d from collections", rs)
|
log.Info("Deleted %d from collections", rs)
|
||||||
|
|
||||||
// Delete tokens
|
// Delete tokens
|
||||||
res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID)
|
res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete access tokens: %v", err)
|
log.Error("Unable to delete access tokens: %v", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ = res.RowsAffected()
|
rs, _ = res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d from accesstokens", rs)
|
log.Info("Deleted %d from accesstokens", rs)
|
||||||
|
|
||||||
|
// Delete user attributes
|
||||||
|
res, err = t.Exec("DELETE FROM oauth_users WHERE user_id = ?", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Unable to delete oauth_users: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rs, _ = res.RowsAffected()
|
||||||
|
log.Info("Deleted %d from oauth_users", rs)
|
||||||
|
|
||||||
// Delete posts
|
// Delete posts
|
||||||
|
// TODO: should maybe get each row so we can federate a delete
|
||||||
|
// if so needs to be outside of transaction like collections
|
||||||
res, err = t.Exec("DELETE FROM posts WHERE owner_id = ?", userID)
|
res, err = t.Exec("DELETE FROM posts WHERE owner_id = ?", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete posts: %v", err)
|
log.Error("Unable to delete posts: %v", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ = res.RowsAffected()
|
rs, _ = res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d from posts", rs)
|
log.Info("Deleted %d from posts", rs)
|
||||||
|
|
||||||
|
// Delete user attributes
|
||||||
res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID)
|
res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete attributes: %v", err)
|
log.Error("Unable to delete attributes: %v", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ = res.RowsAffected()
|
rs, _ = res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d from userattributes", rs)
|
log.Info("Deleted %d from userattributes", rs)
|
||||||
|
|
||||||
|
// Delete user invites
|
||||||
|
res, err = t.Exec("DELETE FROM userinvites WHERE owner_id = ?", userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
log.Error("Unable to delete invites: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rs, _ = res.RowsAffected()
|
||||||
|
log.Info("Deleted %d from userinvites", rs)
|
||||||
|
|
||||||
|
// Delete the user
|
||||||
res, err = t.Exec("DELETE FROM users WHERE id = ?", userID)
|
res, err = t.Exec("DELETE FROM users WHERE id = ?", userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to delete user: %v", err)
|
log.Error("Unable to delete user: %v", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
rs, _ = res.RowsAffected()
|
rs, _ = res.RowsAffected()
|
||||||
stringLogln(l, "Deleted %d from users", rs)
|
log.Info("Deleted %d from users", rs)
|
||||||
|
|
||||||
|
// Commit all changes to the database
|
||||||
err = t.Commit()
|
err = t.Commit()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Rollback()
|
t.Rollback()
|
||||||
stringLogln(l, "Unable to commit: %v", err)
|
log.Error("Unable to commit: %v", err)
|
||||||
return
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
// TODO: federate delete actor
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) {
|
func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) {
|
||||||
|
@ -2272,7 +2376,7 @@ func (db *datastore) GetUserInvite(id string) (*Invite, error) {
|
||||||
var i Invite
|
var i Invite
|
||||||
err := db.QueryRow("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE id = ?", id).Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive)
|
err := db.QueryRow("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE id = ?", id).Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows, db.isIgnorableError(err):
|
||||||
return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."}
|
return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."}
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Error("Failed selecting invite: %v", err)
|
log.Error("Failed selecting invite: %v", err)
|
||||||
|
@ -2453,6 +2557,104 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
|
||||||
return &t, nil
|
return &t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUser int64, inviteCode string) (string, error) {
|
||||||
|
state := store.Generate62RandomString(24)
|
||||||
|
attachUserVal := sql.NullInt64{Valid: attachUser > 0, Int64: attachUser}
|
||||||
|
inviteCodeVal := sql.NullString{Valid: inviteCode != "", String: inviteCode}
|
||||||
|
_, err := db.ExecContext(ctx, "INSERT INTO oauth_client_states (state, provider, client_id, used, created_at, attach_user_id, invite_code) VALUES (?, ?, ?, FALSE, "+db.now()+", ?, ?)", state, provider, clientID, attachUserVal, inviteCodeVal)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unable to record oauth client state: %w", err)
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) {
|
||||||
|
var provider string
|
||||||
|
var clientID string
|
||||||
|
var attachUserID sql.NullInt64
|
||||||
|
var inviteCode sql.NullString
|
||||||
|
err := wf_db.RunTransactionWithOptions(ctx, db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
err := tx.
|
||||||
|
QueryRowContext(ctx, "SELECT provider, client_id, attach_user_id, invite_code FROM oauth_client_states WHERE state = ? AND used = FALSE", state).
|
||||||
|
Scan(&provider, &clientID, &attachUserID, &inviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx, "UPDATE oauth_client_states SET used = TRUE WHERE state = ?", state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rowsAffected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rowsAffected != 1 {
|
||||||
|
return fmt.Errorf("state not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, "", nil
|
||||||
|
}
|
||||||
|
return provider, clientID, attachUserID.Int64, inviteCode.String, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error {
|
||||||
|
var err error
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
_, err = db.ExecContext(ctx, "INSERT OR REPLACE INTO oauth_users (user_id, remote_user_id, provider, client_id, access_token) VALUES (?, ?, ?, ?, ?)", localUserID, remoteUserID, provider, clientID, accessToken)
|
||||||
|
} else {
|
||||||
|
_, err = db.ExecContext(ctx, "INSERT INTO oauth_users (user_id, remote_user_id, provider, client_id, access_token) VALUES (?, ?, ?, ?, ?) "+db.upsert("user")+" access_token = ?", localUserID, remoteUserID, provider, clientID, accessToken, accessToken)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to INSERT oauth_users for '%d': %v", localUserID, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIDForRemoteUser returns a user ID associated with a remote user ID.
|
||||||
|
func (db *datastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) {
|
||||||
|
var userID int64 = -1
|
||||||
|
err := db.
|
||||||
|
QueryRowContext(ctx, "SELECT user_id FROM oauth_users WHERE remote_user_id = ? AND provider = ? AND client_id = ?", remoteUserID, provider, clientID).
|
||||||
|
Scan(&userID)
|
||||||
|
// Not finding a record is OK.
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthAccountInfo struct {
|
||||||
|
Provider string
|
||||||
|
ClientID string
|
||||||
|
RemoteUserID string
|
||||||
|
DisplayName string
|
||||||
|
AllowDisconnect bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetOauthAccounts(ctx context.Context, userID int64) ([]oauthAccountInfo, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT provider, client_id, remote_user_id FROM oauth_users WHERE user_id = ? ", userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed selecting from oauth_users: %v", err)
|
||||||
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user oauth accounts."}
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var records []oauthAccountInfo
|
||||||
|
for rows.Next() {
|
||||||
|
info := oauthAccountInfo{}
|
||||||
|
err = rows.Scan(&info.Provider, &info.ClientID, &info.RemoteUserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed scanning GetAllUsers() row: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
records = append(records, info)
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DatabaseInitialized returns whether or not the current datastore has been
|
// DatabaseInitialized returns whether or not the current datastore has been
|
||||||
// initialized with the correct schema.
|
// initialized with the correct schema.
|
||||||
// Currently, it checks to see if the `users` table exists.
|
// Currently, it checks to see if the `users` table exists.
|
||||||
|
@ -2475,6 +2677,11 @@ func (db *datastore) DatabaseInitialized() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) RemoveOauth(ctx context.Context, userID int64, provider string, clientID string, remoteUserID string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM oauth_users WHERE user_id = ? AND provider = ? AND client_id = ? AND remote_user_id = ?`, userID, provider, clientID, remoteUserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func stringLogln(log *string, s string, v ...interface{}) {
|
func stringLogln(log *string, s string, v ...interface{}) {
|
||||||
*log += fmt.Sprintf(s+"\n", v...)
|
*log += fmt.Sprintf(s+"\n", v...)
|
||||||
}
|
}
|
||||||
|
@ -2483,3 +2690,52 @@ func handleFailedPostInsert(err error) error {
|
||||||
log.Error("Couldn't insert into posts: %v", err)
|
log.Error("Couldn't insert into posts: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) GetProfilePageFromHandle(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("%s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle)
|
||||||
|
}
|
||||||
|
_, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, handle) VALUES(?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), handle)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't insert remote user: %v", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actorIRI = remoteUser.ActorID
|
||||||
|
}
|
||||||
|
return actorIRI, nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,266 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2019-2020 A Bunch Tell 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)
|
||||||
|
}
|
||||||
|
for _, constraint := range b.Constraints {
|
||||||
|
things = append(things, constraint)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,146 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package db
|
||||||
|
|
||||||
|
type RawSqlBuilder struct {
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *RawSqlBuilder) ToSQL() (string, error) {
|
||||||
|
return b.Query, nil
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
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,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -37,6 +37,8 @@ var (
|
||||||
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
|
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
|
||||||
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
|
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."}
|
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||||
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
|
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
|
||||||
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
|
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
|
||||||
|
@ -46,9 +48,12 @@ var (
|
||||||
ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."}
|
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."}
|
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."}
|
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
|
||||||
|
|
||||||
ErrUserSuspended = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
|
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
|
||||||
|
|
||||||
|
ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Post operation errors
|
// Post operation errors
|
||||||
|
|
4
feed.go
|
@ -36,12 +36,12 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view feed: get user: %v", err)
|
log.Error("view feed: get user: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrCollectionNotFound
|
return ErrCollectionNotFound
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
|
53
go.mod
|
@ -1,60 +1,61 @@
|
||||||
module github.com/writeas/writefreely
|
module github.com/writeas/writefreely
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
|
||||||
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
|
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
|
||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // 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/clbanning/mxj v1.8.4 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.0
|
github.com/dustin/go-humanize v1.0.0
|
||||||
github.com/fatih/color v1.7.0
|
github.com/fatih/color v1.9.0
|
||||||
github.com/go-sql-driver/mysql v1.4.1
|
github.com/go-sql-driver/mysql v1.5.0
|
||||||
github.com/go-test/deep v1.0.1 // indirect
|
github.com/go-test/deep v1.0.1 // indirect
|
||||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||||
github.com/gorilla/feeds v1.1.0
|
github.com/gorilla/feeds v1.1.1
|
||||||
github.com/gorilla/mux v1.7.0
|
github.com/gorilla/mux v1.7.4
|
||||||
github.com/gorilla/schema v1.0.2
|
github.com/gorilla/schema v1.2.0
|
||||||
github.com/gorilla/sessions v1.1.3
|
github.com/gorilla/sessions v1.2.0
|
||||||
github.com/guregu/null v3.4.0+incompatible
|
github.com/guregu/null v3.5.0+incompatible
|
||||||
|
github.com/hashicorp/go-multierror v1.1.0
|
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
||||||
|
github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect
|
||||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||||
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
||||||
github.com/manifoldco/promptui v0.3.2
|
github.com/manifoldco/promptui v0.7.0
|
||||||
github.com/mattn/go-colorable v0.1.0 // indirect
|
github.com/mattn/go-sqlite3 v1.14.2
|
||||||
github.com/mattn/go-sqlite3 v1.10.0
|
github.com/microcosm-cc/bluemonday v1.0.3
|
||||||
github.com/microcosm-cc/bluemonday v1.0.2
|
|
||||||
github.com/mitchellh/go-wordwrap v1.0.0
|
github.com/mitchellh/go-wordwrap v1.0.0
|
||||||
github.com/nicksnyder/go-i18n v1.10.0 // indirect
|
github.com/nicksnyder/go-i18n v1.10.0 // indirect
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||||
github.com/pelletier/go-toml v1.2.0 // indirect
|
github.com/pelletier/go-toml v1.2.0 // indirect
|
||||||
github.com/pkg/errors v0.8.1 // indirect
|
github.com/pkg/errors v0.8.1 // indirect
|
||||||
|
github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // 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/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
||||||
github.com/stretchr/testify v1.3.0 // indirect
|
github.com/stretchr/testify v1.6.1
|
||||||
|
github.com/urfave/cli/v2 v2.2.0
|
||||||
github.com/writeas/activity v0.1.2
|
github.com/writeas/activity v0.1.2
|
||||||
|
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481
|
||||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
|
github.com/writeas/go-webfinger v1.1.0
|
||||||
github.com/writeas/httpsig v1.0.0
|
github.com/writeas/httpsig v1.0.0
|
||||||
github.com/writeas/impart v1.1.0
|
github.com/writeas/impart v1.1.1
|
||||||
|
github.com/writeas/import v0.2.1
|
||||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
||||||
github.com/writeas/nerds v1.0.0
|
github.com/writeas/nerds v1.0.0
|
||||||
github.com/writeas/openssl-go v1.0.0 // indirect
|
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
|
||||||
github.com/writeas/saturday v1.7.1
|
|
||||||
github.com/writeas/slug v1.2.0
|
github.com/writeas/slug v1.2.0
|
||||||
github.com/writeas/web-core v1.2.0
|
github.com/writeas/web-core v1.2.1-0.20200813161734-68a680d1b03c
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0
|
github.com/writefreely/go-nodeinfo v1.2.0
|
||||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||||
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 // indirect
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect
|
||||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 // indirect
|
|
||||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
|
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
|
||||||
google.golang.org/appengine v1.4.0 // indirect
|
google.golang.org/appengine v1.4.0 // indirect
|
||||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
|
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
|
||||||
gopkg.in/ini.v1 v1.41.0
|
gopkg.in/ini.v1 v1.57.0
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
|
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect
|
||||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
go 1.13
|
||||||
|
|
139
go.sum
|
@ -1,14 +1,22 @@
|
||||||
|
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
||||||
|
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
|
||||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
|
||||||
github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
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 h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU=
|
||||||
github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
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 h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
|
||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks=
|
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
|
||||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||||
|
github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU=
|
||||||
|
github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE=
|
||||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||||
|
@ -20,46 +28,75 @@ github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||||
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
|
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
||||||
|
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
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/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 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
|
github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
||||||
|
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||||
|
github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY=
|
||||||
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||||
|
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
|
||||||
|
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
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-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
|
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||||
|
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||||
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||||
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
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 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
|
||||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
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.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
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/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
|
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/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 h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY=
|
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/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/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||||
github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk=
|
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/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||||
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
|
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
||||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||||
|
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
|
||||||
|
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA=
|
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/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||||
|
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
|
||||||
|
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||||
|
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
||||||
|
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
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.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
|
||||||
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
|
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
|
||||||
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
||||||
|
github.com/guregu/null v3.5.0+incompatible h1:fSdvRTQtmBA4B4YDZXhLtxTIJZYuUxBFTTHS4B9djG4=
|
||||||
|
github.com/guregu/null v3.5.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 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
|
||||||
|
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
|
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
||||||
|
github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts=
|
||||||
|
github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs=
|
||||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
|
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
|
||||||
|
@ -77,16 +114,29 @@ github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+L
|
||||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
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 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8=
|
||||||
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw=
|
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw=
|
||||||
|
github.com/manifoldco/promptui v0.7.0 h1:3l11YT8tm9MnwGFQ4kETwkzpAwY2Jt9lCrumCUW4+z4=
|
||||||
|
github.com/manifoldco/promptui v0.7.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ=
|
||||||
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
|
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.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 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o=
|
||||||
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||||
|
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
|
||||||
|
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
|
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-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
|
||||||
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
|
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/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.2 h1:A2EQLwjYf/hfYaM20FVjs1UewCTTFR7RmjEHkLjldIA=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.2/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
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/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.3 h1:EjVH7OqbU219kdm8acbveoclh2zZFqPJTJw6VUlTLAQ=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.3/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w=
|
||||||
github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
|
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/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 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q=
|
||||||
|
@ -99,62 +149,106 @@ 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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prologic/go-gopher v0.0.0-20191226035442-664dbdb49f44 h1:q5sit1FpzEt59aM2Fd2lSBKF+nxcY1o0StRCiJa/pWo=
|
||||||
|
github.com/prologic/go-gopher v0.0.0-20191226035442-664dbdb49f44/go.mod h1:a97DSBRiRljeRVd5CRZL5bYCIeeGjSEngGf+QMR2evA=
|
||||||
|
github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 h1:rAbv2gekFbUcjhUkruwo0vMJ0JqhUgg9tz7t+bxHbN4=
|
||||||
|
github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469/go.mod h1:c61IFFAJw8ADWu54tti30Tj5VrBstVoTprmET35UEkY=
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
||||||
|
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||||
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
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/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=
|
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
|
||||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||||
|
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
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 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c=
|
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/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk=
|
||||||
|
github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k=
|
||||||
|
github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||||
|
github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4=
|
||||||
|
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
|
||||||
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
|
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
|
||||||
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
|
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
|
||||||
|
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQFcKoUorpNN3rNwwFG5bITPnqUSyIccfdh0=
|
||||||
|
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
|
||||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
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 v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
||||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||||
|
github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ=
|
||||||
|
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/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
|
github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
|
||||||
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
|
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 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
|
||||||
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
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.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY=
|
||||||
|
github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||||
|
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 v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
|
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/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 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
|
||||||
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
|
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 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
|
||||||
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
||||||
|
github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||||
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
|
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.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 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
||||||
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
||||||
github.com/writeas/web-core v1.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 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
|
||||||
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
|
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
|
||||||
|
github.com/writeas/web-core v1.2.1-0.20200813161734-68a680d1b03c h1:/aPb8WKtC+Ga/xUEcME0iX3VKBeeJ02kXCaROaZ21SE=
|
||||||
|
github.com/writeas/web-core v1.2.1-0.20200813161734-68a680d1b03c/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
||||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
|
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-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg=
|
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/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-20200109152110-61a87790db17 h1:nVJ3guKA9qdkEQ3TUdXI9QSINo2CUPM/cySEvw2w8I0=
|
||||||
|
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
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 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c=
|
||||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
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 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-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006 h1:bfLnR+k0tq5Lqt6dflRLcZiz6UaXCMt3vhYJ1l4FQ80=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
|
||||||
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/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-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 h1:5SvYFrOM3W8Mexn9/oA44Ji7vhXAZQ9hiP+1Q/DMrWg=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY=
|
||||||
|
@ -166,9 +260,14 @@ gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mo
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
|
gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ=
|
||||||
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/ini.v1 v1.57.0 h1:9unxIsFcTt4I55uWluz+UmL95q4kdJ0buvQ1ZIqVQww=
|
||||||
|
gopkg.in/ini.v1 v1.57.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 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
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.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
|
||||||
|
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prologic/go-gopher"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
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
|
||||||
|
|
||||||
|
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{
|
||||||
|
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()
|
||||||
|
}
|
84
handle.go
|
@ -21,6 +21,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/prologic/go-gopher"
|
||||||
"github.com/writeas/impart"
|
"github.com/writeas/impart"
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
"github.com/writeas/writefreely/config"
|
"github.com/writeas/writefreely/config"
|
||||||
|
@ -64,6 +65,7 @@ func UserLevelReader(cfg *config.Config) UserLevel {
|
||||||
|
|
||||||
type (
|
type (
|
||||||
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error
|
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
|
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
|
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)
|
dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
|
||||||
|
@ -73,7 +75,7 @@ type (
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
errors *ErrorPages
|
errors *ErrorPages
|
||||||
sessionStore *sessions.CookieStore
|
sessionStore sessions.Store
|
||||||
app Apper
|
app Apper
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,6 +85,7 @@ type ErrorPages struct {
|
||||||
NotFound *template.Template
|
NotFound *template.Template
|
||||||
Gone *template.Template
|
Gone *template.Template
|
||||||
InternalServerError *template.Template
|
InternalServerError *template.Template
|
||||||
|
UnavailableError *template.Template
|
||||||
Blank *template.Template
|
Blank *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,9 +97,10 @@ 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}}")),
|
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}}")),
|
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}}")),
|
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}}")),
|
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,
|
app: apper,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,6 +115,7 @@ func NewWFHandler(apper Apper) *Handler {
|
||||||
NotFound: pages["404-general.tmpl"],
|
NotFound: pages["404-general.tmpl"],
|
||||||
Gone: pages["410.tmpl"],
|
Gone: pages["410.tmpl"],
|
||||||
InternalServerError: pages["500.tmpl"],
|
InternalServerError: pages["500.tmpl"],
|
||||||
|
UnavailableError: pages["503.tmpl"],
|
||||||
Blank: pages["blank.tmpl"],
|
Blank: pages["blank.tmpl"],
|
||||||
})
|
})
|
||||||
return h
|
return h
|
||||||
|
@ -549,6 +554,37 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.handleError(w, r, func() error {
|
h.handleError(w, r, func() error {
|
||||||
|
@ -565,6 +601,9 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
|
||||||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
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 {
|
if h.app.App().cfg.App.Private {
|
||||||
// This instance is private, so ensure it's being accessed by a valid user
|
// This instance is private, so ensure it's being accessed by a valid user
|
||||||
// Check if authenticated with an access token
|
// Check if authenticated with an access token
|
||||||
|
@ -732,6 +771,10 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
||||||
log.Info("handleHTTPErorr internal error render")
|
log.Info("handleHTTPErorr internal error render")
|
||||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||||
return
|
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 {
|
} else if err.Status == http.StatusAccepted {
|
||||||
impart.WriteSuccess(w, "", err.Status)
|
impart.WriteSuccess(w, "", err.Status)
|
||||||
return
|
return
|
||||||
|
@ -779,6 +822,25 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
|
||||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func correctPageFromLoginAttempt(r *http.Request) string {
|
||||||
to := r.FormValue("to")
|
to := r.FormValue("to")
|
||||||
if to == "" {
|
if to == "" {
|
||||||
|
@ -841,6 +903,24 @@ 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 {
|
func sendRedirect(w http.ResponseWriter, code int, location string) int {
|
||||||
w.Header().Set("Location", location)
|
w.Header().Set("Location", location)
|
||||||
w.WriteHeader(code)
|
w.WriteHeader(code)
|
||||||
|
|
28
invites.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2019 A Bunch Tell LLC.
|
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -42,6 +42,18 @@ func (i Invite) Expired() bool {
|
||||||
return i.Expires != nil && i.Expires.Before(time.Now())
|
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 {
|
func (i Invite) ExpiresFriendly() string {
|
||||||
return i.Expires.Format("January 2, 2006, 3:04 PM")
|
return i.Expires.Format("January 2, 2006, 3:04 PM")
|
||||||
}
|
}
|
||||||
|
@ -57,11 +69,18 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
|
||||||
p := struct {
|
p := struct {
|
||||||
*UserPage
|
*UserPage
|
||||||
Invites *[]Invite
|
Invites *[]Invite
|
||||||
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Invite People", f),
|
UserPage: NewUserPage(app, r, u, "Invite People", f),
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
p.Silenced, err = app.db.IsUserSilenced(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("view invites: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
p.Invites, err = app.db.GetUserInvites(u.ID)
|
p.Invites, err = app.db.GetUserInvites(u.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -79,7 +98,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
|
||||||
expVal := r.FormValue("expires")
|
expVal := r.FormValue("expires")
|
||||||
|
|
||||||
if u.IsSilenced() {
|
if u.IsSilenced() {
|
||||||
return ErrUserSuspended
|
return ErrUserSilenced
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
@ -151,11 +170,13 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
p := struct {
|
p := struct {
|
||||||
page.StaticPage
|
page.StaticPage
|
||||||
|
*OAuthButtons
|
||||||
Error string
|
Error string
|
||||||
Flashes []template.HTML
|
Flashes []template.HTML
|
||||||
Invite string
|
Invite string
|
||||||
}{
|
}{
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
|
OAuthButtons: NewOAuthButtons(app.cfg),
|
||||||
Invite: inviteCode,
|
Invite: inviteCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,6 +184,9 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
p.Error = "This invite link has 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
|
// Get error messages
|
||||||
session, err := app.sessionStore.Get(r, cookieName)
|
session, err := app.sessionStore.Get(r, cookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -13,19 +13,38 @@ nav#admin {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
a {
|
a {
|
||||||
color: @primary;
|
|
||||||
&:first-child {
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
.rounded(.25em);
|
||||||
|
border: 0;
|
||||||
&.selected {
|
&.selected {
|
||||||
|
background: #dedede;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
.blip {
|
||||||
|
color: black;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.blip {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.pager {
|
.pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
|
&:not(.pages) {
|
||||||
|
display: block;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
a {
|
||||||
|
margin-left: 0;
|
||||||
|
.rounded(.25em);
|
||||||
|
|
||||||
|
&+a {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #333;
|
color: #333;
|
||||||
font-family: @sansFont;
|
font-family: @sansFont;
|
||||||
|
@ -42,3 +61,39 @@ nav#admin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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,6 +5,7 @@
|
||||||
@import "post-temp";
|
@import "post-temp";
|
||||||
@import "effects";
|
@import "effects";
|
||||||
@import "admin";
|
@import "admin";
|
||||||
|
@import "login";
|
||||||
@import "pages/error";
|
@import "pages/error";
|
||||||
@import "lib/elements";
|
@import "lib/elements";
|
||||||
@import "lib/material";
|
@import "lib/material";
|
||||||
|
|
129
less/core.less
|
@ -10,6 +10,8 @@
|
||||||
@proSelectedCol: #71D571;
|
@proSelectedCol: #71D571;
|
||||||
@textLinkColor: rgb(0, 0, 238);
|
@textLinkColor: rgb(0, 0, 238);
|
||||||
|
|
||||||
|
@accent: #767676;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: @serifFont;
|
font-family: @serifFont;
|
||||||
font-size-adjust: 0.5;
|
font-size-adjust: 0.5;
|
||||||
|
@ -81,7 +83,7 @@ body {
|
||||||
font-size: 1.5em;
|
font-size: 1.5em;
|
||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.17em;
|
font-size: 1.4em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -524,12 +526,12 @@ pre, body#post article, #post .alert, #subpage .alert, body#collection article,
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
p {
|
p {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
textarea, pre, body#post article, body#collection article p {
|
textarea, pre, body#post article, body#collection article p {
|
||||||
&.norm, &.sans, &.wrap {
|
&.norm, &.sans, &.wrap {
|
||||||
line-height: 1.4em;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap; /* CSS 3 */
|
white-space: pre-wrap; /* CSS 3 */
|
||||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||||
white-space: -pre-wrap; /* Opera 4-6 */
|
white-space: -pre-wrap; /* Opera 4-6 */
|
||||||
|
@ -639,6 +641,23 @@ 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 {
|
body#collection article, body#subpage article {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
@ -684,18 +703,19 @@ select.inputform, textarea.inputform {
|
||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
input, button, select.inputform, textarea.inputform {
|
input, button, select.inputform, textarea.inputform, a.btn {
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
font-family: @serifFont;
|
font-family: @serifFont;
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
.rounded(.25em);
|
.rounded(.25em);
|
||||||
&[type=submit], &.submit {
|
&[type=submit], &.submit, &.cta {
|
||||||
border: 1px solid @primary;
|
border: 1px solid @primary;
|
||||||
background: @primary;
|
background: @primary;
|
||||||
color: white;
|
color: white;
|
||||||
.transition(0.2s);
|
.transition(0.2s);
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: lighten(@primary, 3%);
|
background-color: lighten(@primary, 3%);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
@ -725,6 +745,18 @@ input, button, select.inputform, textarea.inputform {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn.pager {
|
||||||
|
border: 1px solid @lightNavBorder;
|
||||||
|
font-size: .86em;
|
||||||
|
padding: .5em 1em;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: @sansFont;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
background: @lightNavBorder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
div.flat-select {
|
div.flat-select {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -793,9 +825,6 @@ input {
|
||||||
&.snug {
|
&.snug {
|
||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
}
|
}
|
||||||
&.regular {
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
.app {
|
.app {
|
||||||
+ .app {
|
+ .app {
|
||||||
margin-top: 1.5em;
|
margin-top: 1.5em;
|
||||||
|
@ -812,7 +841,7 @@ input {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
li {
|
li {
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
|
@ -867,20 +896,6 @@ input {
|
||||||
text-align: center;
|
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 {
|
div.blurbs {
|
||||||
>h2 {
|
>h2 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -964,7 +979,12 @@ footer.contain-me {
|
||||||
}
|
}
|
||||||
ul {
|
ul {
|
||||||
&.collections {
|
&.collections {
|
||||||
|
padding-left: 0;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
li {
|
li {
|
||||||
&.collection {
|
&.collection {
|
||||||
a.title {
|
a.title {
|
||||||
|
@ -1006,7 +1026,7 @@ footer.contain-me {
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
|
|
||||||
.item-desc, .prog-lang {
|
.item-desc, .prog-lang {
|
||||||
font-size: 0.6em;
|
font-size: 0.6em;
|
||||||
|
@ -1094,7 +1114,8 @@ body#pad-sub #posts, .atoms {
|
||||||
}
|
}
|
||||||
.electron {
|
.electron {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin-left: 0.5em;
|
font-size: 0.86em;
|
||||||
|
margin-left: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h3, h4 {
|
h3, h4 {
|
||||||
|
@ -1244,7 +1265,7 @@ header {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&.singleuser {
|
&.singleuser {
|
||||||
margin: 0.5em 0.25em;
|
margin: 0.5em 1em 0.5em 0.25em;
|
||||||
nav#user-nav {
|
nav#user-nav {
|
||||||
nav > ul > li:first-child {
|
nav > ul > li:first-child {
|
||||||
img {
|
img {
|
||||||
|
@ -1252,6 +1273,9 @@ header {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.right-side {
|
||||||
|
padding-top: 0.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.dash-nav {
|
.dash-nav {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -1317,6 +1341,24 @@ form {
|
||||||
font-size: 0.86em;
|
font-size: 0.86em;
|
||||||
line-height: 2;
|
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 {
|
div.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -1326,6 +1368,16 @@ div.row {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.check, .blip {
|
||||||
|
font-size: 1.125em;
|
||||||
|
color: #71D571;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ex.failure {
|
||||||
|
font-weight: bold;
|
||||||
|
color: @dangerCol;
|
||||||
|
}
|
||||||
|
|
||||||
@media all and (max-width: 450px) {
|
@media all and (max-width: 450px) {
|
||||||
body#post {
|
body#post {
|
||||||
header {
|
header {
|
||||||
|
@ -1392,7 +1444,7 @@ div.row {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media all and (max-width: 600px) {
|
@media all and (max-width: 600px) {
|
||||||
div.row {
|
div.row:not(.admin-actions) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.half {
|
.half {
|
||||||
|
@ -1518,3 +1570,26 @@ div.row {
|
||||||
pre.code-block {
|
pre.code-block {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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: #999;
|
@actionNavColor: #767676;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -58,7 +58,7 @@ header {
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
&.description {
|
&.description {
|
||||||
color: #666;
|
color: #444;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
@ -113,7 +113,7 @@ textarea {
|
||||||
ul {
|
ul {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 0 1em;
|
padding: 0 0 0 1em;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
|
|
||||||
&.collections, &.posts, &.integrations {
|
&.collections, &.posts, &.integrations {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -127,7 +127,6 @@ textarea {
|
||||||
&.collection {
|
&.collection {
|
||||||
a.title {
|
a.title {
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -206,7 +205,7 @@ code, textarea#embed {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
li {
|
li {
|
||||||
margin: 0.3em 0;
|
margin: 0.3em 0;
|
||||||
|
|
|
@ -60,7 +60,7 @@
|
||||||
&:hover {
|
&:hover {
|
||||||
background: @lightNavHoverBG;
|
background: @lightNavHoverBG;
|
||||||
}
|
}
|
||||||
&:hover > ul {
|
&:hover > ul, &.open > ul {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
&.selected {
|
&.selected {
|
||||||
|
@ -361,6 +361,24 @@ body#pad {
|
||||||
z-index: 10;
|
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) {
|
@media all and (max-height: 500px) {
|
||||||
body#pad {
|
body#pad {
|
||||||
textarea {
|
textarea {
|
||||||
|
@ -425,6 +443,10 @@ body#pad {
|
||||||
padding-left: 10%;
|
padding-left: 10%;
|
||||||
padding-right: 10%;
|
padding-right: 10%;
|
||||||
}
|
}
|
||||||
|
.alert {
|
||||||
|
left: 10%;
|
||||||
|
right: 10%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media all and (min-width: 60em) {
|
@media all and (min-width: 60em) {
|
||||||
|
@ -433,6 +455,10 @@ body#pad {
|
||||||
padding-left: 15%;
|
padding-left: 15%;
|
||||||
padding-right: 15%;
|
padding-right: 15%;
|
||||||
}
|
}
|
||||||
|
.alert {
|
||||||
|
left: 15%;
|
||||||
|
right: 15%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media all and (min-width: 70em) {
|
@media all and (min-width: 70em) {
|
||||||
|
@ -441,6 +467,10 @@ body#pad {
|
||||||
padding-left: 20%;
|
padding-left: 20%;
|
||||||
padding-right: 20%;
|
padding-right: 20%;
|
||||||
}
|
}
|
||||||
|
.alert {
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media all and (min-width: 85em) {
|
@media all and (min-width: 85em) {
|
||||||
|
@ -449,6 +479,10 @@ body#pad {
|
||||||
padding-left: 25%;
|
padding-left: 25%;
|
||||||
padding-right: 25%;
|
padding-right: 25%;
|
||||||
}
|
}
|
||||||
|
.alert {
|
||||||
|
left: 25%;
|
||||||
|
right: 25%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media all and (min-width: 105em) {
|
@media all and (min-width: 105em) {
|
||||||
|
@ -457,6 +491,10 @@ body#pad {
|
||||||
padding-left: 30%;
|
padding-left: 30%;
|
||||||
padding-right: 30%;
|
padding-right: 30%;
|
||||||
}
|
}
|
||||||
|
.alert {
|
||||||
|
left: 30%;
|
||||||
|
right: 30%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
|
|
|
@ -17,6 +17,16 @@ body {
|
||||||
font-size: 1.6em;
|
font-size: 1.6em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
article {
|
||||||
|
h2#title.dated {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
time.dt-published {
|
||||||
|
display: block;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,7 +49,7 @@ body#post article, pre, .hljs {
|
||||||
border-left: 4px solid #ddd;
|
border-left: 4px solid #ddd;
|
||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
margin: 0.5em;
|
margin: 0.5em;
|
||||||
color: #777;
|
color: #767676;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
|
@ -48,7 +58,7 @@ body#post article, pre, .hljs {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.article-p() {
|
.article-p() {
|
||||||
line-height: 1.4em;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap; /* CSS 3 */
|
white-space: pre-wrap; /* CSS 3 */
|
||||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||||
white-space: -pre-wrap; /* Opera 4-6 */
|
white-space: -pre-wrap; /* Opera 4-6 */
|
||||||
|
|
|
@ -0,0 +1,153 @@
|
||||||
|
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)
|
||||||
|
}
|
|
@ -78,3 +78,10 @@ func (db *datastore) engine() string {
|
||||||
}
|
}
|
||||||
return " ENGINE = InnoDB"
|
return " ENGINE = InnoDB"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) after(colName string) string {
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " AFTER " + colName
|
||||||
|
}
|
||||||
|
|
|
@ -59,6 +59,13 @@ var migrations = []Migration{
|
||||||
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
|
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
|
||||||
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
|
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
|
||||||
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.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
|
||||||
}
|
}
|
||||||
|
|
||||||
// CurrentVer returns the current migration version the application is on
|
// CurrentVer returns the current migration version the application is on
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2019-2020 A Bunch Tell 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/writeas/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
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2019-2020 A Bunch Tell 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/writeas/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
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2019-2020 A Bunch Tell 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()
|
||||||
|
|
||||||
|
_, 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
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
wf_db "github.com/writeas/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
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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/writeas/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
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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 optimizeDrafts(db *datastore) error {
|
||||||
|
t, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
_, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`)
|
||||||
|
} else {
|
||||||
|
_, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = t.Commit()
|
||||||
|
if err != nil {
|
||||||
|
t.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,462 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2019-2020 A Bunch Tell 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 (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/writeas/impart"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
"github.com/writeas/writefreely/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthButtons holds display information for different OAuth providers we support.
|
||||||
|
type OAuthButtons struct {
|
||||||
|
SlackEnabled bool
|
||||||
|
WriteAsEnabled bool
|
||||||
|
GitLabEnabled bool
|
||||||
|
GitLabDisplayName string
|
||||||
|
GiteaEnabled bool
|
||||||
|
GiteaDisplayName string
|
||||||
|
GenericEnabled bool
|
||||||
|
GenericDisplayName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration.
|
||||||
|
func NewOAuthButtons(cfg *config.Config) *OAuthButtons {
|
||||||
|
return &OAuthButtons{
|
||||||
|
SlackEnabled: cfg.SlackOauth.ClientID != "",
|
||||||
|
WriteAsEnabled: cfg.WriteAsOauth.ClientID != "",
|
||||||
|
GitLabEnabled: cfg.GitlabOauth.ClientID != "",
|
||||||
|
GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName),
|
||||||
|
GiteaEnabled: cfg.GiteaOauth.ClientID != "",
|
||||||
|
GiteaDisplayName: config.OrDefaultString(cfg.GiteaOauth.DisplayName, giteaDisplayName),
|
||||||
|
GenericEnabled: cfg.GenericOauth.ClientID != "",
|
||||||
|
GenericDisplayName: config.OrDefaultString(cfg.GenericOauth.DisplayName, genericOauthDisplayName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse contains data returned when a token is created either
|
||||||
|
// through a code exchange or using a refresh token.
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectResponse contains data returned when an access token is inspected.
|
||||||
|
type InspectResponse struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"-"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRequestMaxLen is the most bytes that we'll read from the /oauth/token
|
||||||
|
// endpoint. One megabyte is plenty.
|
||||||
|
const tokenRequestMaxLen = 1000000
|
||||||
|
|
||||||
|
// infoRequestMaxLen is the most bytes that we'll read from the
|
||||||
|
// /oauth/inspect endpoint.
|
||||||
|
const infoRequestMaxLen = 1000000
|
||||||
|
|
||||||
|
// OAuthDatastoreProvider provides a minimal interface of data store, config,
|
||||||
|
// and session store for use with the oauth handlers.
|
||||||
|
type OAuthDatastoreProvider interface {
|
||||||
|
DB() OAuthDatastore
|
||||||
|
Config() *config.Config
|
||||||
|
SessionStore() sessions.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthDatastore provides a minimal interface of data store methods used in
|
||||||
|
// oauth functionality.
|
||||||
|
type OAuthDatastore interface {
|
||||||
|
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||||
|
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||||
|
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||||
|
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||||
|
|
||||||
|
CreateUser(*config.Config, *User, string) error
|
||||||
|
GetUserByID(int64) (*User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HttpClient interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthClient interface {
|
||||||
|
GetProvider() string
|
||||||
|
GetClientID() string
|
||||||
|
GetCallbackLocation() string
|
||||||
|
buildLoginURL(state string) (string, error)
|
||||||
|
exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error)
|
||||||
|
inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type callbackProxyClient struct {
|
||||||
|
server string
|
||||||
|
callbackLocation string
|
||||||
|
httpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthHandler struct {
|
||||||
|
Config *config.Config
|
||||||
|
DB OAuthDatastore
|
||||||
|
Store sessions.Store
|
||||||
|
EmailKey []byte
|
||||||
|
oauthClient oauthClient
|
||||||
|
callbackProxy *callbackProxyClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var attachUser int64
|
||||||
|
if attach := r.URL.Query().Get("attach"); attach == "t" {
|
||||||
|
user, _ := getUserAndSession(app, r)
|
||||||
|
if user == nil {
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, "cannot attach auth to user: user not found in session"}
|
||||||
|
}
|
||||||
|
attachUser = user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser, r.FormValue("invite_code"))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("viewOauthInit error: %s", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.callbackProxy != nil {
|
||||||
|
if err := h.callbackProxy.register(ctx, state); err != nil {
|
||||||
|
log.Error("viewOauthInit error: %s", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, "could not register state server"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := h.oauthClient.buildLoginURL(state)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("viewOauthInit error: %s", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||||
|
}
|
||||||
|
return impart.HTTPError{http.StatusTemporaryRedirect, location}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureSlackOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
|
if app.Config().SlackOauth.ClientID != "" {
|
||||||
|
callbackLocation := app.Config().App.Host + "/oauth/callback/slack"
|
||||||
|
|
||||||
|
var stateRegisterClient *callbackProxyClient = nil
|
||||||
|
if app.Config().SlackOauth.CallbackProxyAPI != "" {
|
||||||
|
stateRegisterClient = &callbackProxyClient{
|
||||||
|
server: app.Config().SlackOauth.CallbackProxyAPI,
|
||||||
|
callbackLocation: app.Config().App.Host + "/oauth/callback/slack",
|
||||||
|
httpClient: config.DefaultHTTPClient(),
|
||||||
|
}
|
||||||
|
callbackLocation = app.Config().SlackOauth.CallbackProxy
|
||||||
|
}
|
||||||
|
oauthClient := slackOauthClient{
|
||||||
|
ClientID: app.Config().SlackOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().SlackOauth.ClientSecret,
|
||||||
|
TeamID: app.Config().SlackOauth.TeamID,
|
||||||
|
HttpClient: config.DefaultHTTPClient(),
|
||||||
|
CallbackLocation: callbackLocation,
|
||||||
|
}
|
||||||
|
configureOauthRoutes(parentHandler, r, app, oauthClient, stateRegisterClient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
|
if app.Config().WriteAsOauth.ClientID != "" {
|
||||||
|
callbackLocation := app.Config().App.Host + "/oauth/callback/write.as"
|
||||||
|
|
||||||
|
var callbackProxy *callbackProxyClient = nil
|
||||||
|
if app.Config().WriteAsOauth.CallbackProxy != "" {
|
||||||
|
callbackProxy = &callbackProxyClient{
|
||||||
|
server: app.Config().WriteAsOauth.CallbackProxyAPI,
|
||||||
|
callbackLocation: app.Config().App.Host + "/oauth/callback/write.as",
|
||||||
|
httpClient: config.DefaultHTTPClient(),
|
||||||
|
}
|
||||||
|
callbackLocation = app.Config().WriteAsOauth.CallbackProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthClient := writeAsOauthClient{
|
||||||
|
ClientID: app.Config().WriteAsOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
|
||||||
|
ExchangeLocation: config.OrDefaultString(app.Config().WriteAsOauth.TokenLocation, writeAsExchangeLocation),
|
||||||
|
InspectLocation: config.OrDefaultString(app.Config().WriteAsOauth.InspectLocation, writeAsIdentityLocation),
|
||||||
|
AuthLocation: config.OrDefaultString(app.Config().WriteAsOauth.AuthLocation, writeAsAuthLocation),
|
||||||
|
HttpClient: config.DefaultHTTPClient(),
|
||||||
|
CallbackLocation: callbackLocation,
|
||||||
|
}
|
||||||
|
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureGitlabOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
|
if app.Config().GitlabOauth.ClientID != "" {
|
||||||
|
callbackLocation := app.Config().App.Host + "/oauth/callback/gitlab"
|
||||||
|
|
||||||
|
var callbackProxy *callbackProxyClient = nil
|
||||||
|
if app.Config().GitlabOauth.CallbackProxy != "" {
|
||||||
|
callbackProxy = &callbackProxyClient{
|
||||||
|
server: app.Config().GitlabOauth.CallbackProxyAPI,
|
||||||
|
callbackLocation: app.Config().App.Host + "/oauth/callback/gitlab",
|
||||||
|
httpClient: config.DefaultHTTPClient(),
|
||||||
|
}
|
||||||
|
callbackLocation = app.Config().GitlabOauth.CallbackProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
address := config.OrDefaultString(app.Config().GitlabOauth.Host, gitlabHost)
|
||||||
|
oauthClient := gitlabOauthClient{
|
||||||
|
ClientID: app.Config().GitlabOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().GitlabOauth.ClientSecret,
|
||||||
|
ExchangeLocation: address + "/oauth/token",
|
||||||
|
InspectLocation: address + "/api/v4/user",
|
||||||
|
AuthLocation: address + "/oauth/authorize",
|
||||||
|
HttpClient: config.DefaultHTTPClient(),
|
||||||
|
CallbackLocation: callbackLocation,
|
||||||
|
}
|
||||||
|
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureGenericOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
|
if app.Config().GenericOauth.ClientID != "" {
|
||||||
|
callbackLocation := app.Config().App.Host + "/oauth/callback/generic"
|
||||||
|
|
||||||
|
var callbackProxy *callbackProxyClient = nil
|
||||||
|
if app.Config().GenericOauth.CallbackProxy != "" {
|
||||||
|
callbackProxy = &callbackProxyClient{
|
||||||
|
server: app.Config().GenericOauth.CallbackProxyAPI,
|
||||||
|
callbackLocation: app.Config().App.Host + "/oauth/callback/generic",
|
||||||
|
httpClient: config.DefaultHTTPClient(),
|
||||||
|
}
|
||||||
|
callbackLocation = app.Config().GenericOauth.CallbackProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthClient := genericOauthClient{
|
||||||
|
ClientID: app.Config().GenericOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().GenericOauth.ClientSecret,
|
||||||
|
ExchangeLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.TokenEndpoint,
|
||||||
|
InspectLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.InspectEndpoint,
|
||||||
|
AuthLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.AuthEndpoint,
|
||||||
|
HttpClient: config.DefaultHTTPClient(),
|
||||||
|
CallbackLocation: callbackLocation,
|
||||||
|
}
|
||||||
|
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureGiteaOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||||
|
if app.Config().GiteaOauth.ClientID != "" {
|
||||||
|
callbackLocation := app.Config().App.Host + "/oauth/callback/gitea"
|
||||||
|
|
||||||
|
var callbackProxy *callbackProxyClient = nil
|
||||||
|
if app.Config().GiteaOauth.CallbackProxy != "" {
|
||||||
|
callbackProxy = &callbackProxyClient{
|
||||||
|
server: app.Config().GiteaOauth.CallbackProxyAPI,
|
||||||
|
callbackLocation: app.Config().App.Host + "/oauth/callback/gitea",
|
||||||
|
httpClient: config.DefaultHTTPClient(),
|
||||||
|
}
|
||||||
|
callbackLocation = app.Config().GiteaOauth.CallbackProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthClient := giteaOauthClient{
|
||||||
|
ClientID: app.Config().GiteaOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().GiteaOauth.ClientSecret,
|
||||||
|
ExchangeLocation: app.Config().GiteaOauth.Host + "/login/oauth/access_token",
|
||||||
|
InspectLocation: app.Config().GiteaOauth.Host + "/api/v1/user",
|
||||||
|
AuthLocation: app.Config().GiteaOauth.Host + "/login/oauth/authorize",
|
||||||
|
HttpClient: config.DefaultHTTPClient(),
|
||||||
|
CallbackLocation: callbackLocation,
|
||||||
|
}
|
||||||
|
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
|
||||||
|
handler := &oauthHandler{
|
||||||
|
Config: app.Config(),
|
||||||
|
DB: app.DB(),
|
||||||
|
Store: app.SessionStore(),
|
||||||
|
oauthClient: oauthClient,
|
||||||
|
EmailKey: app.keys.EmailKey,
|
||||||
|
callbackProxy: callbackProxy,
|
||||||
|
}
|
||||||
|
r.HandleFunc("/oauth/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthInit)).Methods("GET")
|
||||||
|
r.HandleFunc("/oauth/callback/"+oauthClient.GetProvider(), parentHandler.OAuth(handler.viewOauthCallback)).Methods("GET")
|
||||||
|
r.HandleFunc("/oauth/signup", parentHandler.OAuth(handler.viewOauthSignup)).Methods("POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
code := r.FormValue("code")
|
||||||
|
state := r.FormValue("state")
|
||||||
|
|
||||||
|
provider, clientID, attachUserID, inviteCode, err := h.DB.ValidateOAuthState(ctx, state)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to ValidateOAuthState: %s", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to exchangeOauthCode: %s", err)
|
||||||
|
// TODO: show user friendly message if needed
|
||||||
|
// TODO: show NO message for cases like user pressing "Cancel" on authorize step
|
||||||
|
addSessionFlash(app, w, r, err.Error(), nil)
|
||||||
|
if attachUserID > 0 {
|
||||||
|
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||||
|
}
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that we have the access token, let's use it real quick to make sure
|
||||||
|
// it really really works.
|
||||||
|
tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to inspectOauthAccessToken: %s", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
localUserID, err := h.DB.GetIDForRemoteUser(ctx, tokenInfo.UserID, provider, clientID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to GetIDForRemoteUser: %s", err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
if localUserID != -1 && attachUserID > 0 {
|
||||||
|
if err = addSessionFlash(app, w, r, "This Slack account is already attached to another user.", nil); err != nil {
|
||||||
|
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if localUserID != -1 {
|
||||||
|
// Existing user, so log in now
|
||||||
|
user, err := h.DB.GetUserByID(localUserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to GetUserByID %d: %s", localUserID, err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
if err = loginOrFail(h.Store, w, r, user); err != nil {
|
||||||
|
log.Error("Unable to loginOrFail %d: %s", localUserID, err)
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if attachUserID > 0 {
|
||||||
|
log.Info("attaching to user %d", attachUserID)
|
||||||
|
err = h.DB.RecordRemoteUserID(r.Context(), attachUserID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New user registration below.
|
||||||
|
// First, verify that user is allowed to register
|
||||||
|
if inviteCode != "" {
|
||||||
|
// Verify invite code is valid
|
||||||
|
i, err := app.db.GetUserInvite(inviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||||
|
}
|
||||||
|
if !i.Active(app.db) {
|
||||||
|
return impart.HTTPError{http.StatusNotFound, "Invite link has expired."}
|
||||||
|
}
|
||||||
|
} else if !app.cfg.App.OpenRegistration {
|
||||||
|
addSessionFlash(app, w, r, ErrUserNotFound.Error(), nil)
|
||||||
|
return impart.HTTPError{http.StatusFound, "/login"}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := tokenInfo.DisplayName
|
||||||
|
if len(displayName) == 0 {
|
||||||
|
displayName = tokenInfo.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
tp := &oauthSignupPageParams{
|
||||||
|
AccessToken: tokenResponse.AccessToken,
|
||||||
|
TokenUsername: tokenInfo.Username,
|
||||||
|
TokenAlias: tokenInfo.DisplayName,
|
||||||
|
TokenEmail: tokenInfo.Email,
|
||||||
|
TokenRemoteUser: tokenInfo.UserID,
|
||||||
|
Provider: provider,
|
||||||
|
ClientID: clientID,
|
||||||
|
InviteCode: inviteCode,
|
||||||
|
}
|
||||||
|
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||||
|
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *callbackProxyClient) register(ctx context.Context, state string) error {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("state", state)
|
||||||
|
form.Add("location", r.callbackLocation)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", r.server, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
return fmt.Errorf("unable register state location: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
|
||||||
|
lr := io.LimitReader(body, int64(n+1))
|
||||||
|
data, err := ioutil.ReadAll(lr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(data) == n+1 {
|
||||||
|
return fmt.Errorf("content larger than max read allowance: %d", n)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, thing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginOrFail(store sessions.Store, w http.ResponseWriter, r *http.Request, user *User) error {
|
||||||
|
// An error may be returned, but a valid session should always be returned.
|
||||||
|
session, _ := store.Get(r, cookieName)
|
||||||
|
session.Values[cookieUserVal] = user.Cookie()
|
||||||
|
if err := session.Save(r, w); err != nil {
|
||||||
|
fmt.Println("error saving session", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package oauth
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// ClientStateStore provides state management used by the OAuth client.
|
||||||
|
type ClientStateStore interface {
|
||||||
|
Generate(ctx context.Context) (string, error)
|
||||||
|
Validate(ctx context.Context, state string) error
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type genericOauthClient struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
AuthLocation string
|
||||||
|
ExchangeLocation string
|
||||||
|
InspectLocation string
|
||||||
|
CallbackLocation string
|
||||||
|
HttpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ oauthClient = genericOauthClient{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
genericOauthDisplayName = "OAuth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c genericOauthClient) GetProvider() string {
|
||||||
|
return "generic"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c genericOauthClient) GetClientID() string {
|
||||||
|
return c.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c genericOauthClient) GetCallbackLocation() string {
|
||||||
|
return c.CallbackLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c genericOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
|
u, err := url.Parse(c.AuthLocation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("client_id", c.ClientID)
|
||||||
|
q.Set("redirect_uri", c.CallbackLocation)
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("state", state)
|
||||||
|
q.Set("scope", "read_user")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("grant_type", "authorization_code")
|
||||||
|
form.Add("redirect_uri", c.CallbackLocation)
|
||||||
|
form.Add("scope", "read_user")
|
||||||
|
form.Add("code", code)
|
||||||
|
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to exchange code for access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse TokenResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tokenResponse.Error != "" {
|
||||||
|
return nil, errors.New(tokenResponse.Error)
|
||||||
|
}
|
||||||
|
return &tokenResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||||
|
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to inspect access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var inspectResponse InspectResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if inspectResponse.Error != "" {
|
||||||
|
return nil, errors.New(inspectResponse.Error)
|
||||||
|
}
|
||||||
|
return &inspectResponse, nil
|
||||||
|
}
|
|
@ -0,0 +1,114 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type giteaOauthClient struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
AuthLocation string
|
||||||
|
ExchangeLocation string
|
||||||
|
InspectLocation string
|
||||||
|
CallbackLocation string
|
||||||
|
HttpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ oauthClient = giteaOauthClient{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
giteaDisplayName = "Gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c giteaOauthClient) GetProvider() string {
|
||||||
|
return "gitea"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c giteaOauthClient) GetClientID() string {
|
||||||
|
return c.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c giteaOauthClient) GetCallbackLocation() string {
|
||||||
|
return c.CallbackLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c giteaOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
|
u, err := url.Parse(c.AuthLocation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("client_id", c.ClientID)
|
||||||
|
q.Set("redirect_uri", c.CallbackLocation)
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("state", state)
|
||||||
|
// q.Set("scope", "read_user")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c giteaOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("grant_type", "authorization_code")
|
||||||
|
form.Add("redirect_uri", c.CallbackLocation)
|
||||||
|
// form.Add("scope", "read_user")
|
||||||
|
form.Add("code", code)
|
||||||
|
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to exchange code for access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse TokenResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tokenResponse.Error != "" {
|
||||||
|
return nil, errors.New(tokenResponse.Error)
|
||||||
|
}
|
||||||
|
return &tokenResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c giteaOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||||
|
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to inspect access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var inspectResponse InspectResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if inspectResponse.Error != "" {
|
||||||
|
return nil, errors.New(inspectResponse.Error)
|
||||||
|
}
|
||||||
|
return &inspectResponse, nil
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gitlabOauthClient struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
AuthLocation string
|
||||||
|
ExchangeLocation string
|
||||||
|
InspectLocation string
|
||||||
|
CallbackLocation string
|
||||||
|
HttpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ oauthClient = gitlabOauthClient{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
gitlabHost = "https://gitlab.com"
|
||||||
|
gitlabDisplayName = "GitLab"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c gitlabOauthClient) GetProvider() string {
|
||||||
|
return "gitlab"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c gitlabOauthClient) GetClientID() string {
|
||||||
|
return c.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c gitlabOauthClient) GetCallbackLocation() string {
|
||||||
|
return c.CallbackLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c gitlabOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
|
u, err := url.Parse(c.AuthLocation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("client_id", c.ClientID)
|
||||||
|
q.Set("redirect_uri", c.CallbackLocation)
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("state", state)
|
||||||
|
q.Set("scope", "read_user")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c gitlabOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("grant_type", "authorization_code")
|
||||||
|
form.Add("redirect_uri", c.CallbackLocation)
|
||||||
|
form.Add("scope", "read_user")
|
||||||
|
form.Add("code", code)
|
||||||
|
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to exchange code for access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse TokenResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tokenResponse.Error != "" {
|
||||||
|
return nil, errors.New(tokenResponse.Error)
|
||||||
|
}
|
||||||
|
return &tokenResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||||
|
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to inspect access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var inspectResponse InspectResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if inspectResponse.Error != "" {
|
||||||
|
return nil, errors.New(inspectResponse.Error)
|
||||||
|
}
|
||||||
|
return &inspectResponse, nil
|
||||||
|
}
|
|
@ -0,0 +1,231 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2020 A Bunch Tell 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 (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"github.com/writeas/impart"
|
||||||
|
"github.com/writeas/web-core/auth"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
|
"github.com/writeas/writefreely/page"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type viewOauthSignupVars struct {
|
||||||
|
page.StaticPage
|
||||||
|
To string
|
||||||
|
Message template.HTML
|
||||||
|
Flashes []template.HTML
|
||||||
|
|
||||||
|
AccessToken string
|
||||||
|
TokenUsername string
|
||||||
|
TokenAlias string // TODO: rename this to match the data it represents: the collection title
|
||||||
|
TokenEmail string
|
||||||
|
TokenRemoteUser string
|
||||||
|
Provider string
|
||||||
|
ClientID string
|
||||||
|
TokenHash string
|
||||||
|
InviteCode string
|
||||||
|
|
||||||
|
LoginUsername string
|
||||||
|
Alias string // TODO: rename this to match the data it represents: the collection title
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
oauthParamAccessToken = "access_token"
|
||||||
|
oauthParamTokenUsername = "token_username"
|
||||||
|
oauthParamTokenAlias = "token_alias"
|
||||||
|
oauthParamTokenEmail = "token_email"
|
||||||
|
oauthParamTokenRemoteUserID = "token_remote_user"
|
||||||
|
oauthParamClientID = "client_id"
|
||||||
|
oauthParamProvider = "provider"
|
||||||
|
oauthParamHash = "signature"
|
||||||
|
oauthParamUsername = "username"
|
||||||
|
oauthParamAlias = "alias"
|
||||||
|
oauthParamEmail = "email"
|
||||||
|
oauthParamPassword = "password"
|
||||||
|
oauthParamInviteCode = "invite_code"
|
||||||
|
)
|
||||||
|
|
||||||
|
type oauthSignupPageParams struct {
|
||||||
|
AccessToken string
|
||||||
|
TokenUsername string
|
||||||
|
TokenAlias string // TODO: rename this to match the data it represents: the collection title
|
||||||
|
TokenEmail string
|
||||||
|
TokenRemoteUser string
|
||||||
|
ClientID string
|
||||||
|
Provider string
|
||||||
|
TokenHash string
|
||||||
|
InviteCode string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p oauthSignupPageParams) HashTokenParams(key string) string {
|
||||||
|
hasher := sha256.New()
|
||||||
|
hasher.Write([]byte(key))
|
||||||
|
hasher.Write([]byte(p.AccessToken))
|
||||||
|
hasher.Write([]byte(p.TokenUsername))
|
||||||
|
hasher.Write([]byte(p.TokenAlias))
|
||||||
|
hasher.Write([]byte(p.TokenEmail))
|
||||||
|
hasher.Write([]byte(p.TokenRemoteUser))
|
||||||
|
hasher.Write([]byte(p.ClientID))
|
||||||
|
hasher.Write([]byte(p.Provider))
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) viewOauthSignup(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
tp := &oauthSignupPageParams{
|
||||||
|
AccessToken: r.FormValue(oauthParamAccessToken),
|
||||||
|
TokenUsername: r.FormValue(oauthParamTokenUsername),
|
||||||
|
TokenAlias: r.FormValue(oauthParamTokenAlias),
|
||||||
|
TokenEmail: r.FormValue(oauthParamTokenEmail),
|
||||||
|
TokenRemoteUser: r.FormValue(oauthParamTokenRemoteUserID),
|
||||||
|
ClientID: r.FormValue(oauthParamClientID),
|
||||||
|
Provider: r.FormValue(oauthParamProvider),
|
||||||
|
InviteCode: r.FormValue(oauthParamInviteCode),
|
||||||
|
}
|
||||||
|
if tp.HashTokenParams(h.Config.Server.HashSeed) != r.FormValue(oauthParamHash) {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Request has been tampered with."}
|
||||||
|
}
|
||||||
|
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||||
|
if err := h.validateOauthSignup(r); err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
hashedPass := []byte{}
|
||||||
|
clearPass := r.FormValue(oauthParamPassword)
|
||||||
|
hasPass := clearPass != ""
|
||||||
|
if hasPass {
|
||||||
|
hashedPass, err = auth.HashPass([]byte(clearPass))
|
||||||
|
if err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, fmt.Errorf("unable to hash password"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newUser := &User{
|
||||||
|
Username: r.FormValue(oauthParamUsername),
|
||||||
|
HashedPass: hashedPass,
|
||||||
|
HasPass: hasPass,
|
||||||
|
Email: prepareUserEmail(r.FormValue(oauthParamEmail), h.EmailKey),
|
||||||
|
Created: time.Now().Truncate(time.Second).UTC(),
|
||||||
|
}
|
||||||
|
displayName := r.FormValue(oauthParamAlias)
|
||||||
|
if len(displayName) == 0 {
|
||||||
|
displayName = r.FormValue(oauthParamUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.DB.CreateUser(h.Config, newUser, displayName)
|
||||||
|
if err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log invite if needed
|
||||||
|
if tp.InviteCode != "" {
|
||||||
|
err = app.db.CreateInvitedUser(tp.InviteCode, newUser.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.DB.RecordRemoteUserID(r.Context(), newUser.ID, r.FormValue(oauthParamTokenRemoteUserID), r.FormValue(oauthParamProvider), r.FormValue(oauthParamClientID), r.FormValue(oauthParamAccessToken))
|
||||||
|
if err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := loginOrFail(h.Store, w, r, newUser); err != nil {
|
||||||
|
return h.showOauthSignupPage(app, w, r, tp, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) validateOauthSignup(r *http.Request) error {
|
||||||
|
username := r.FormValue(oauthParamUsername)
|
||||||
|
if len(username) < h.Config.App.MinUsernameLen {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too short."}
|
||||||
|
}
|
||||||
|
if len(username) > 100 {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Username is too long."}
|
||||||
|
}
|
||||||
|
collTitle := r.FormValue(oauthParamAlias)
|
||||||
|
if len(collTitle) == 0 {
|
||||||
|
collTitle = username
|
||||||
|
}
|
||||||
|
email := r.FormValue(oauthParamEmail)
|
||||||
|
if len(email) > 0 {
|
||||||
|
parts := strings.Split(email, "@")
|
||||||
|
if len(parts) != 2 || (len(parts[0]) < 1 || len(parts[1]) < 1) {
|
||||||
|
return impart.HTTPError{Status: http.StatusBadRequest, Message: "Invalid email address"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h oauthHandler) showOauthSignupPage(app *App, w http.ResponseWriter, r *http.Request, tp *oauthSignupPageParams, errMsg error) error {
|
||||||
|
username := tp.TokenUsername
|
||||||
|
collTitle := tp.TokenAlias
|
||||||
|
email := tp.TokenEmail
|
||||||
|
|
||||||
|
session, err := app.sessionStore.Get(r, cookieName)
|
||||||
|
if err != nil {
|
||||||
|
// Ignore this
|
||||||
|
log.Error("Unable to get session; ignoring: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpValue := r.FormValue(oauthParamUsername); len(tmpValue) > 0 {
|
||||||
|
username = tmpValue
|
||||||
|
}
|
||||||
|
if tmpValue := r.FormValue(oauthParamAlias); len(tmpValue) > 0 {
|
||||||
|
collTitle = tmpValue
|
||||||
|
}
|
||||||
|
if tmpValue := r.FormValue(oauthParamEmail); len(tmpValue) > 0 {
|
||||||
|
email = tmpValue
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &viewOauthSignupVars{
|
||||||
|
StaticPage: pageForReq(app, r),
|
||||||
|
To: r.FormValue("to"),
|
||||||
|
Flashes: []template.HTML{},
|
||||||
|
|
||||||
|
AccessToken: tp.AccessToken,
|
||||||
|
TokenUsername: tp.TokenUsername,
|
||||||
|
TokenAlias: tp.TokenAlias,
|
||||||
|
TokenEmail: tp.TokenEmail,
|
||||||
|
TokenRemoteUser: tp.TokenRemoteUser,
|
||||||
|
Provider: tp.Provider,
|
||||||
|
ClientID: tp.ClientID,
|
||||||
|
TokenHash: tp.TokenHash,
|
||||||
|
InviteCode: tp.InviteCode,
|
||||||
|
|
||||||
|
LoginUsername: username,
|
||||||
|
Alias: collTitle,
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display any error messages
|
||||||
|
flashes, _ := getSessionFlashes(app, w, r, session)
|
||||||
|
for _, flash := range flashes {
|
||||||
|
p.Flashes = append(p.Flashes, template.HTML(flash))
|
||||||
|
}
|
||||||
|
if errMsg != nil {
|
||||||
|
p.Flashes = append(p.Flashes, template.HTML(errMsg.Error()))
|
||||||
|
}
|
||||||
|
err = pages["signup-oauth.tmpl"].ExecuteTemplate(w, "base", p)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Unable to render signup-oauth: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,178 @@
|
||||||
|
/*
|
||||||
|
* Copyright © 2019-2020 A Bunch Tell 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 (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"github.com/writeas/slug"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type slackOauthClient struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
TeamID string
|
||||||
|
CallbackLocation string
|
||||||
|
HttpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackExchangeResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
TeamName string `json:"team_name"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackIdentity struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackTeam struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type slackUserIdentityResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
User slackIdentity `json:"user"`
|
||||||
|
Team slackTeam `json:"team"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
slackAuthLocation = "https://slack.com/oauth/authorize"
|
||||||
|
slackExchangeLocation = "https://slack.com/api/oauth.access"
|
||||||
|
slackIdentityLocation = "https://slack.com/api/users.identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ oauthClient = slackOauthClient{}
|
||||||
|
|
||||||
|
func (c slackOauthClient) GetProvider() string {
|
||||||
|
return "slack"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c slackOauthClient) GetClientID() string {
|
||||||
|
return c.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c slackOauthClient) GetCallbackLocation() string {
|
||||||
|
return c.CallbackLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c slackOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
|
u, err := url.Parse(slackAuthLocation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("client_id", c.ClientID)
|
||||||
|
q.Set("scope", "identity.basic identity.email identity.team")
|
||||||
|
q.Set("redirect_uri", c.CallbackLocation)
|
||||||
|
q.Set("state", state)
|
||||||
|
|
||||||
|
// If this param is not set, the user can select which team they
|
||||||
|
// authenticate through and then we'd have to match the configured team
|
||||||
|
// against the profile get. That is extra work in the post-auth phase
|
||||||
|
// that we don't want to do.
|
||||||
|
q.Set("team", c.TeamID)
|
||||||
|
|
||||||
|
// The Slack OAuth docs don't explicitly list this one, but it is part of
|
||||||
|
// the spec, so we include it anyway.
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c slackOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
// The oauth.access documentation doesn't explicitly mention this
|
||||||
|
// parameter, but it is part of the spec, so we include it anyway.
|
||||||
|
// https://api.slack.com/methods/oauth.access
|
||||||
|
form.Add("grant_type", "authorization_code")
|
||||||
|
form.Add("redirect_uri", c.CallbackLocation)
|
||||||
|
form.Add("code", code)
|
||||||
|
req, err := http.NewRequest("POST", slackExchangeLocation, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to exchange code for access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse slackExchangeResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !tokenResponse.OK {
|
||||||
|
return nil, errors.New(tokenResponse.Error)
|
||||||
|
}
|
||||||
|
return tokenResponse.TokenResponse(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c slackOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||||
|
req, err := http.NewRequest("GET", slackIdentityLocation, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to inspect access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var inspectResponse slackUserIdentityResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !inspectResponse.OK {
|
||||||
|
return nil, errors.New(inspectResponse.Error)
|
||||||
|
}
|
||||||
|
return inspectResponse.InspectResponse(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (resp slackUserIdentityResponse) InspectResponse() *InspectResponse {
|
||||||
|
return &InspectResponse{
|
||||||
|
UserID: resp.User.ID,
|
||||||
|
Username: slug.Make(resp.User.Name),
|
||||||
|
DisplayName: resp.User.Name,
|
||||||
|
Email: resp.User.Email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (resp slackExchangeResponse) TokenResponse() *TokenResponse {
|
||||||
|
return &TokenResponse{
|
||||||
|
AccessToken: resp.AccessToken,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,251 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/writeas/impart"
|
||||||
|
"github.com/writeas/nerds/store"
|
||||||
|
"github.com/writeas/writefreely/config"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockOAuthDatastoreProvider struct {
|
||||||
|
DoDB func() OAuthDatastore
|
||||||
|
DoConfig func() *config.Config
|
||||||
|
DoSessionStore func() sessions.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockOAuthDatastore struct {
|
||||||
|
DoGenerateOAuthState func(context.Context, string, string, int64, string) (string, error)
|
||||||
|
DoValidateOAuthState func(context.Context, string) (string, string, int64, string, error)
|
||||||
|
DoGetIDForRemoteUser func(context.Context, string, string, string) (int64, error)
|
||||||
|
DoCreateUser func(*config.Config, *User, string) error
|
||||||
|
DoRecordRemoteUserID func(context.Context, int64, string, string, string, string) error
|
||||||
|
DoGetUserByID func(int64) (*User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ OAuthDatastore = &MockOAuthDatastore{}
|
||||||
|
|
||||||
|
type StringReadCloser struct {
|
||||||
|
*strings.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (src *StringReadCloser) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockHTTPClient struct {
|
||||||
|
DoDo func(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
if m.DoDo != nil {
|
||||||
|
return m.DoDo(req)
|
||||||
|
}
|
||||||
|
return &http.Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastoreProvider) SessionStore() sessions.Store {
|
||||||
|
if m.DoSessionStore != nil {
|
||||||
|
return m.DoSessionStore()
|
||||||
|
}
|
||||||
|
return sessions.NewCookieStore([]byte("secret-key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastoreProvider) DB() OAuthDatastore {
|
||||||
|
if m.DoDB != nil {
|
||||||
|
return m.DoDB()
|
||||||
|
}
|
||||||
|
return &MockOAuthDatastore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastoreProvider) Config() *config.Config {
|
||||||
|
if m.DoConfig != nil {
|
||||||
|
return m.DoConfig()
|
||||||
|
}
|
||||||
|
cfg := config.New()
|
||||||
|
cfg.UseSQLite(true)
|
||||||
|
cfg.WriteAsOauth = config.WriteAsOauthCfg{
|
||||||
|
ClientID: "development",
|
||||||
|
ClientSecret: "development",
|
||||||
|
AuthLocation: "https://write.as/oauth/login",
|
||||||
|
TokenLocation: "https://write.as/oauth/token",
|
||||||
|
InspectLocation: "https://write.as/oauth/inspect",
|
||||||
|
}
|
||||||
|
cfg.SlackOauth = config.SlackOauthCfg{
|
||||||
|
ClientID: "development",
|
||||||
|
ClientSecret: "development",
|
||||||
|
TeamID: "development",
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastore) ValidateOAuthState(ctx context.Context, state string) (string, string, int64, string, error) {
|
||||||
|
if m.DoValidateOAuthState != nil {
|
||||||
|
return m.DoValidateOAuthState(ctx, state)
|
||||||
|
}
|
||||||
|
return "", "", 0, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastore) GetIDForRemoteUser(ctx context.Context, remoteUserID, provider, clientID string) (int64, error) {
|
||||||
|
if m.DoGetIDForRemoteUser != nil {
|
||||||
|
return m.DoGetIDForRemoteUser(ctx, remoteUserID, provider, clientID)
|
||||||
|
}
|
||||||
|
return -1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastore) CreateUser(cfg *config.Config, u *User, username string) error {
|
||||||
|
if m.DoCreateUser != nil {
|
||||||
|
return m.DoCreateUser(cfg, u, username)
|
||||||
|
}
|
||||||
|
u.ID = 1
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastore) RecordRemoteUserID(ctx context.Context, localUserID int64, remoteUserID, provider, clientID, accessToken string) error {
|
||||||
|
if m.DoRecordRemoteUserID != nil {
|
||||||
|
return m.DoRecordRemoteUserID(ctx, localUserID, remoteUserID, provider, clientID, accessToken)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastore) GetUserByID(userID int64) (*User, error) {
|
||||||
|
if m.DoGetUserByID != nil {
|
||||||
|
return m.DoGetUserByID(userID)
|
||||||
|
}
|
||||||
|
user := &User{}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockOAuthDatastore) GenerateOAuthState(ctx context.Context, provider string, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||||
|
if m.DoGenerateOAuthState != nil {
|
||||||
|
return m.DoGenerateOAuthState(ctx, provider, clientID, attachUserID, inviteCode)
|
||||||
|
}
|
||||||
|
return store.Generate62RandomString(14), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewOauthInit(t *testing.T) {
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
app := &MockOAuthDatastoreProvider{}
|
||||||
|
h := oauthHandler{
|
||||||
|
Config: app.Config(),
|
||||||
|
DB: app.DB(),
|
||||||
|
Store: app.SessionStore(),
|
||||||
|
EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd},
|
||||||
|
oauthClient: writeAsOauthClient{
|
||||||
|
ClientID: app.Config().WriteAsOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
|
||||||
|
ExchangeLocation: app.Config().WriteAsOauth.TokenLocation,
|
||||||
|
InspectLocation: app.Config().WriteAsOauth.InspectLocation,
|
||||||
|
AuthLocation: app.Config().WriteAsOauth.AuthLocation,
|
||||||
|
CallbackLocation: "http://localhost/oauth/callback",
|
||||||
|
HttpClient: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("GET", "/oauth/client", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
err = h.viewOauthInit(nil, rr, req)
|
||||||
|
assert.NotNil(t, err)
|
||||||
|
httpErr, ok := err.(impart.HTTPError)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, http.StatusTemporaryRedirect, httpErr.Status)
|
||||||
|
assert.NotEmpty(t, httpErr.Message)
|
||||||
|
locURI, err := url.Parse(httpErr.Message)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "/oauth/login", locURI.Path)
|
||||||
|
assert.Equal(t, "development", locURI.Query().Get("client_id"))
|
||||||
|
assert.Equal(t, "http://localhost/oauth/callback", locURI.Query().Get("redirect_uri"))
|
||||||
|
assert.Equal(t, "code", locURI.Query().Get("response_type"))
|
||||||
|
assert.NotEmpty(t, locURI.Query().Get("state"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("state failure", func(t *testing.T) {
|
||||||
|
app := &MockOAuthDatastoreProvider{
|
||||||
|
DoDB: func() OAuthDatastore {
|
||||||
|
return &MockOAuthDatastore{
|
||||||
|
DoGenerateOAuthState: func(ctx context.Context, provider, clientID string, attachUserID int64, inviteCode string) (string, error) {
|
||||||
|
return "", fmt.Errorf("pretend unable to write state error")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := oauthHandler{
|
||||||
|
Config: app.Config(),
|
||||||
|
DB: app.DB(),
|
||||||
|
Store: app.SessionStore(),
|
||||||
|
EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd},
|
||||||
|
oauthClient: writeAsOauthClient{
|
||||||
|
ClientID: app.Config().WriteAsOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
|
||||||
|
ExchangeLocation: app.Config().WriteAsOauth.TokenLocation,
|
||||||
|
InspectLocation: app.Config().WriteAsOauth.InspectLocation,
|
||||||
|
AuthLocation: app.Config().WriteAsOauth.AuthLocation,
|
||||||
|
CallbackLocation: "http://localhost/oauth/callback",
|
||||||
|
HttpClient: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("GET", "/oauth/client", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
err = h.viewOauthInit(nil, rr, req)
|
||||||
|
httpErr, ok := err.(impart.HTTPError)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotEmpty(t, httpErr.Message)
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, httpErr.Status)
|
||||||
|
assert.Equal(t, "could not prepare oauth redirect url", httpErr.Message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewOauthCallback(t *testing.T) {
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
app := &MockOAuthDatastoreProvider{}
|
||||||
|
h := oauthHandler{
|
||||||
|
Config: app.Config(),
|
||||||
|
DB: app.DB(),
|
||||||
|
Store: app.SessionStore(),
|
||||||
|
EmailKey: []byte{0xd, 0xe, 0xc, 0xa, 0xf, 0xf, 0xb, 0xa, 0xd},
|
||||||
|
oauthClient: writeAsOauthClient{
|
||||||
|
ClientID: app.Config().WriteAsOauth.ClientID,
|
||||||
|
ClientSecret: app.Config().WriteAsOauth.ClientSecret,
|
||||||
|
ExchangeLocation: app.Config().WriteAsOauth.TokenLocation,
|
||||||
|
InspectLocation: app.Config().WriteAsOauth.InspectLocation,
|
||||||
|
AuthLocation: app.Config().WriteAsOauth.AuthLocation,
|
||||||
|
CallbackLocation: "http://localhost/oauth/callback",
|
||||||
|
HttpClient: &MockHTTPClient{
|
||||||
|
DoDo: func(req *http.Request) (*http.Response, error) {
|
||||||
|
switch req.URL.String() {
|
||||||
|
case "https://write.as/oauth/token":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: &StringReadCloser{strings.NewReader(`{"access_token": "access_token", "expires_in": 1000, "refresh_token": "refresh_token", "token_type": "access"}`)},
|
||||||
|
}, nil
|
||||||
|
case "https://write.as/oauth/inspect":
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: &StringReadCloser{strings.NewReader(`{"client_id": "development", "user_id": "1", "expires_at": "2019-12-19T11:42:01Z", "username": "nick", "email": "nick@testing.write.as"}`)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusNotFound,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("GET", "/oauth/callback", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
err = h.viewOauthCallback(nil, rr, req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,114 @@
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type writeAsOauthClient struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
AuthLocation string
|
||||||
|
ExchangeLocation string
|
||||||
|
InspectLocation string
|
||||||
|
CallbackLocation string
|
||||||
|
HttpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ oauthClient = writeAsOauthClient{}
|
||||||
|
|
||||||
|
const (
|
||||||
|
writeAsAuthLocation = "https://write.as/oauth/login"
|
||||||
|
writeAsExchangeLocation = "https://write.as/oauth/token"
|
||||||
|
writeAsIdentityLocation = "https://write.as/oauth/inspect"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c writeAsOauthClient) GetProvider() string {
|
||||||
|
return "write.as"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c writeAsOauthClient) GetClientID() string {
|
||||||
|
return c.ClientID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c writeAsOauthClient) GetCallbackLocation() string {
|
||||||
|
return c.CallbackLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c writeAsOauthClient) buildLoginURL(state string) (string, error) {
|
||||||
|
u, err := url.Parse(c.AuthLocation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("client_id", c.ClientID)
|
||||||
|
q.Set("redirect_uri", c.CallbackLocation)
|
||||||
|
q.Set("response_type", "code")
|
||||||
|
q.Set("state", state)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c writeAsOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("grant_type", "authorization_code")
|
||||||
|
form.Add("redirect_uri", c.CallbackLocation)
|
||||||
|
form.Add("code", code)
|
||||||
|
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to exchange code for access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse TokenResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tokenResponse.Error != "" {
|
||||||
|
return nil, errors.New(tokenResponse.Error)
|
||||||
|
}
|
||||||
|
return &tokenResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c writeAsOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||||
|
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.WithContext(ctx)
|
||||||
|
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
resp, err := c.HttpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New("unable to inspect access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var inspectResponse InspectResponse
|
||||||
|
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if inspectResponse.Error != "" {
|
||||||
|
return nil, errors.New(inspectResponse.Error)
|
||||||
|
}
|
||||||
|
return &inspectResponse, nil
|
||||||
|
}
|
14
pad.go
|
@ -38,7 +38,7 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
Post *RawPost
|
Post *RawPost
|
||||||
User *User
|
User *User
|
||||||
Blogs *[]Collection
|
Blogs *[]Collection
|
||||||
Suspended bool
|
Silenced bool
|
||||||
|
|
||||||
Editing bool // True if we're modifying an existing post
|
Editing bool // True if we're modifying an existing post
|
||||||
EditCollection *Collection // Collection of the post we're editing, if any
|
EditCollection *Collection // Collection of the post we're editing, if any
|
||||||
|
@ -53,9 +53,9 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to get user's blogs for Pad: %v", err)
|
log.Error("Unable to get user's blogs for Pad: %v", err)
|
||||||
}
|
}
|
||||||
appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID)
|
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to get users suspension status for Pad: %v", err)
|
log.Error("Unable to get user status for Pad: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,6 +92,7 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
appData.EditCollection.hostName = app.cfg.App.Host
|
||||||
} else {
|
} else {
|
||||||
// Editing a floating article
|
// Editing a floating article
|
||||||
appData.Post = getRawPost(app, action)
|
appData.Post = getRawPost(app, action)
|
||||||
|
@ -126,16 +127,16 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
EditCollection *Collection // Collection of the post we're editing, if any
|
EditCollection *Collection // Collection of the post we're editing, if any
|
||||||
Flashes []string
|
Flashes []string
|
||||||
NeedsToken bool
|
NeedsToken bool
|
||||||
Suspended bool
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
Post: &RawPost{Font: "norm"},
|
Post: &RawPost{Font: "norm"},
|
||||||
User: getUserSession(app, r),
|
User: getUserSession(app, r),
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID)
|
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view meta: get user suspended status: %v", err)
|
log.Error("view meta: get user status: %v", err)
|
||||||
return ErrInternalGeneral
|
return ErrInternalGeneral
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,6 +162,7 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
appData.EditCollection.hostName = app.cfg.App.Host
|
||||||
} else {
|
} else {
|
||||||
// Editing a floating article
|
// Editing a floating article
|
||||||
appData.Post = getRawPost(app, action)
|
appData.Post = getRawPost(app, action)
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
{{define "head"}}<title>Temporarily Unavailable — {{.SiteMetaName}}</title>{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="error-page">
|
||||||
|
<p class="msg">The words aren't coming to me. 🗅</p>
|
||||||
|
<p>We couldn't serve this page due to high server load. This should only be temporary.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
|
@ -60,6 +60,9 @@ form dd {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
max-width: 8em;
|
max-width: 8em;
|
||||||
}
|
}
|
||||||
|
.or {
|
||||||
|
margin-bottom: 2.5em !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
|
@ -73,6 +76,8 @@ form dd {
|
||||||
|
|
||||||
<div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}>
|
<div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}>
|
||||||
{{ if .OpenRegistration }}
|
{{ if .OpenRegistration }}
|
||||||
|
{{template "oauth-buttons" .}}
|
||||||
|
{{if not .DisablePasswordAuth}}
|
||||||
{{if .Flashes}}<ul class="errors">
|
{{if .Flashes}}<ul class="errors">
|
||||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
</ul>{{end}}
|
</ul>{{end}}
|
||||||
|
@ -101,6 +106,7 @@ form dd {
|
||||||
</dl>
|
</dl>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p>
|
<p style="font-size: 1.3em; margin: 1rem 0;">Registration is currently closed.</p>
|
||||||
<p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p>
|
<p>You can always sign up on <a href="https://writefreely.org/instances">another instance</a>.</p>
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
||||||
<meta name="description" content="Log in to {{.SiteName}}.">
|
<meta name="description" content="Log in to {{.SiteName}}.">
|
||||||
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
<meta itemprop="description" content="Log in to {{.SiteName}}.">
|
||||||
<style>input{margin-bottom:0.5em;}</style>
|
<style>
|
||||||
|
input{margin-bottom:0.5em;}
|
||||||
|
</style>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="tight content-container">
|
<div class="tight content-container">
|
||||||
|
@ -11,6 +13,9 @@
|
||||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
</ul>{{end}}
|
</ul>{{end}}
|
||||||
|
|
||||||
|
{{template "oauth-buttons" .}}
|
||||||
|
|
||||||
|
{{if not .DisablePasswordAuth}}
|
||||||
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
||||||
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
|
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
|
||||||
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
||||||
|
@ -18,13 +23,14 @@
|
||||||
<input type="submit" id="btn-login" value="Login" />
|
<input type="submit" id="btn-login" value="Login" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="/">Sign up</a> to start a blog.{{end}}</p>{{end}}
|
{{if and (not .SingleUser) .OpenRegistration}}<p style="text-align:center;font-size:0.9em;margin:3em auto;max-width:26em;">{{if .Message}}{{.Message}}{{else}}<em>No account yet?</em> <a href="{{.SignupPath}}">Sign up</a> to start a blog.{{end}}</p>{{end}}
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function disableSubmit() {
|
function disableSubmit() {
|
||||||
var $btn = document.getElementById("btn-login");
|
var $btn = document.getElementById("btn-login");
|
||||||
$btn.value = "Logging in...";
|
$btn.value = "Logging in...";
|
||||||
$btn.disabled = true;
|
$btn.disabled = true;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -0,0 +1,186 @@
|
||||||
|
{{define "head"}}<title>Finish Creating Account — {{.SiteName}}</title>
|
||||||
|
<style>input{margin-bottom:0.5em;}</style>
|
||||||
|
<style type="text/css">
|
||||||
|
h2 {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
#pricing.content-container div.form-container #payment-form {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
#pricing #signup-form table {
|
||||||
|
max-width: inherit !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#pricing #payment-form table {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
max-width: inherit !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
tr.subscription {
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
#pricing.content-container tr.subscription button {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#pricing tr.subscription td {
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
#pricing table.billing > tbody > tr > td:first-child {
|
||||||
|
vertical-align: middle !important;
|
||||||
|
}
|
||||||
|
.billing-section {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.billing-section.bill-me {
|
||||||
|
display: table-row;
|
||||||
|
}
|
||||||
|
#btn-create {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
#total-price {
|
||||||
|
padding-left: 0.5em;
|
||||||
|
}
|
||||||
|
#alias-site.demo {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
#alias-site {
|
||||||
|
text-align: left;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
form dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div id="pricing" class="tight content-container">
|
||||||
|
<h1>Finish creating account</h1>
|
||||||
|
|
||||||
|
{{if .Flashes}}<ul class="errors">
|
||||||
|
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||||
|
</ul>{{end}}
|
||||||
|
|
||||||
|
<div id="billing">
|
||||||
|
<form action="/oauth/signup" method="post" style="text-align: center;margin-top:1em;" onsubmit="return disableSubmit()">
|
||||||
|
<input type="hidden" name="access_token" value="{{ .AccessToken }}" />
|
||||||
|
<input type="hidden" name="token_username" value="{{ .TokenUsername }}" />
|
||||||
|
<input type="hidden" name="token_alias" value="{{ .TokenAlias }}" />
|
||||||
|
<input type="hidden" name="token_email" value="{{ .TokenEmail }}" />
|
||||||
|
<input type="hidden" name="token_remote_user" value="{{ .TokenRemoteUser }}" />
|
||||||
|
<input type="hidden" name="provider" value="{{ .Provider }}" />
|
||||||
|
<input type="hidden" name="client_id" value="{{ .ClientID }}" />
|
||||||
|
<input type="hidden" name="signature" value="{{ .TokenHash }}" />
|
||||||
|
{{if .InviteCode}}<input type="hidden" name="invite_code" value="{{ .InviteCode }}" />{{end}}
|
||||||
|
|
||||||
|
<dl class="billing">
|
||||||
|
<label>
|
||||||
|
<dt>Display Name</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="text" style="width: 100%; box-sizing: border-box;" name="alias" placeholder="Name"{{ if .Alias }} value="{{.Alias}}"{{ end }} />
|
||||||
|
</dd>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<dt>Username</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="text" id="username" name="username" style="width: 100%; box-sizing: border-box;" placeholder="Username" value="{{.LoginUsername}}" /><br />
|
||||||
|
{{if .Federation}}<p id="alias-site" class="demo">@<strong>your-username</strong>@{{.FriendlyHost}}</p>{{else}}<p id="alias-site" class="demo">{{.FriendlyHost}}/<strong>your-username</strong></p>{{end}}
|
||||||
|
</dd>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>
|
||||||
|
<input type="text" name="email" style="width: 100%; box-sizing: border-box;" placeholder="Email"{{ if .Email }} value="{{.Email}}"{{ end }} />
|
||||||
|
</dd>
|
||||||
|
</label>
|
||||||
|
<dt>
|
||||||
|
<input type="submit" id="btn-login" value="Next" />
|
||||||
|
</dt>
|
||||||
|
</dl>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="/js/h.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
// Copied from signup.tmpl
|
||||||
|
// NOTE: this element is named "alias" on signup.tmpl and "username" here
|
||||||
|
var $alias = H.getEl('username');
|
||||||
|
|
||||||
|
function disableSubmit() {
|
||||||
|
// Validate input
|
||||||
|
if (!aliasOK) {
|
||||||
|
var $a = $alias;
|
||||||
|
$a.el.className = 'error';
|
||||||
|
$a.el.focus();
|
||||||
|
$a.el.scrollIntoView();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var $btn = document.getElementById("btn-login");
|
||||||
|
$btn.value = "Logging in...";
|
||||||
|
$btn.disabled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copied from signup.tmpl
|
||||||
|
var $aliasSite = document.getElementById('alias-site');
|
||||||
|
var aliasOK = true;
|
||||||
|
var typingTimer;
|
||||||
|
var doneTypingInterval = 750;
|
||||||
|
var doneTyping = function(genID) {
|
||||||
|
// Check on username
|
||||||
|
var alias = $alias.el.value;
|
||||||
|
if (alias != "") {
|
||||||
|
var params = {
|
||||||
|
username: alias
|
||||||
|
};
|
||||||
|
var http = new XMLHttpRequest();
|
||||||
|
http.open("POST", '/api/alias', true);
|
||||||
|
|
||||||
|
// Send the proper header information along with the request
|
||||||
|
http.setRequestHeader("Content-type", "application/json");
|
||||||
|
|
||||||
|
http.onreadystatechange = function() {
|
||||||
|
if (http.readyState == 4) {
|
||||||
|
data = JSON.parse(http.responseText);
|
||||||
|
if (http.status == 200) {
|
||||||
|
aliasOK = true;
|
||||||
|
$alias.removeClass('error');
|
||||||
|
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)demo(?!\S)/g, '');
|
||||||
|
$aliasSite.className = $aliasSite.className.replace(/(?:^|\s)error(?!\S)/g, '');
|
||||||
|
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>' + data.data + '</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>' + data.data + '</strong>/{{ end }}';
|
||||||
|
} else {
|
||||||
|
if (genID === true) {
|
||||||
|
$alias.el.value = alias + "-" + randStr(4);
|
||||||
|
doneTyping();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aliasOK = false;
|
||||||
|
$alias.setClass('error');
|
||||||
|
$aliasSite.className = 'error';
|
||||||
|
$aliasSite.textContent = data.error_msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.send(JSON.stringify(params));
|
||||||
|
} else {
|
||||||
|
$aliasSite.className += ' demo';
|
||||||
|
$aliasSite.innerHTML = '{{ if .Federation }}@<strong>your-username</strong>@{{.FriendlyHost}}{{ else }}{{.FriendlyHost}}/<strong>your-username</strong>/{{ end }}';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$alias.on('keyup input', function() {
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(doneTyping, doneTypingInterval);
|
||||||
|
});
|
||||||
|
function randStr(len) {
|
||||||
|
var res = '';
|
||||||
|
var chars = '23456789bcdfghjklmnpqrstvwxyz';
|
||||||
|
for (var i=0; i<len; i++) {
|
||||||
|
res += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
doneTyping(true);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
|
@ -70,6 +70,9 @@ form dd {
|
||||||
</ul>{{end}}
|
</ul>{{end}}
|
||||||
|
|
||||||
<div id="billing">
|
<div id="billing">
|
||||||
|
{{template "oauth-buttons" .}}
|
||||||
|
|
||||||
|
{{if not .DisablePasswordAuth}}
|
||||||
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
|
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
|
||||||
<input type="hidden" name="invite_code" value="{{.Invite}}" />
|
<input type="hidden" name="invite_code" value="{{.Invite}}" />
|
||||||
<dl class="billing">
|
<dl class="billing">
|
||||||
|
@ -93,6 +96,7 @@ form dd {
|
||||||
</dt>
|
</dt>
|
||||||
</dl>
|
</dl>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -11,9 +11,11 @@
|
||||||
package writefreely
|
package writefreely
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
@ -21,7 +23,9 @@ import (
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
stripmd "github.com/writeas/go-strip-markdown"
|
stripmd "github.com/writeas/go-strip-markdown"
|
||||||
|
"github.com/writeas/impart"
|
||||||
blackfriday "github.com/writeas/saturday"
|
blackfriday "github.com/writeas/saturday"
|
||||||
|
"github.com/writeas/web-core/log"
|
||||||
"github.com/writeas/web-core/stringmanip"
|
"github.com/writeas/web-core/stringmanip"
|
||||||
"github.com/writeas/writefreely/config"
|
"github.com/writeas/writefreely/config"
|
||||||
"github.com/writeas/writefreely/parse"
|
"github.com/writeas/writefreely/parse"
|
||||||
|
@ -34,6 +38,7 @@ var (
|
||||||
titleElementReg = regexp.MustCompile("</?h[1-6]>")
|
titleElementReg = regexp.MustCompile("</?h[1-6]>")
|
||||||
hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
|
hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
|
||||||
markeddownReg = regexp.MustCompile("<p>(.+)</p>")
|
markeddownReg = regexp.MustCompile("<p>(.+)</p>")
|
||||||
|
mentionReg = regexp.MustCompile(`@([A-Za-z0-9._%+-]+)(@[A-Za-z0-9.-]+\.[A-Za-z]+)\b`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) {
|
func (p *Post) formatContent(cfg *config.Config, c *Collection, isOwner bool) {
|
||||||
|
@ -53,6 +58,17 @@ func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
|
||||||
p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
|
p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *Post) augmentContent(c *Collection) {
|
||||||
|
// Add post signatures
|
||||||
|
if c.Signature != "" {
|
||||||
|
p.Content += "\n\n" + c.Signature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PublicPost) augmentContent() {
|
||||||
|
p.Post.augmentContent(&p.Collection.Collection)
|
||||||
|
}
|
||||||
|
|
||||||
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
|
func applyMarkdown(data []byte, baseURL string, cfg *config.Config) string {
|
||||||
return applyMarkdownSpecial(data, false, baseURL, cfg)
|
return applyMarkdownSpecial(data, false, baseURL, cfg)
|
||||||
}
|
}
|
||||||
|
@ -82,6 +98,8 @@ func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *c
|
||||||
tagPrefix = "/read/t/"
|
tagPrefix = "/read/t/"
|
||||||
}
|
}
|
||||||
md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>")))
|
md = []byte(hashtagReg.ReplaceAll(md, []byte("<a href=\""+tagPrefix+"$1\" class=\"hashtag\"><span>#</span><span class=\"p-category\">$1</span></a>")))
|
||||||
|
handlePrefix := cfg.App.Host + "/@/"
|
||||||
|
md = []byte(mentionReg.ReplaceAll(md, []byte("<a href=\""+handlePrefix+"$1$2\" class=\"u-url mention\">@<span>$1$2</span></a>")))
|
||||||
}
|
}
|
||||||
// Strip out bad HTML
|
// Strip out bad HTML
|
||||||
policy := getSanitizationPolicy()
|
policy := getSanitizationPolicy()
|
||||||
|
@ -172,6 +190,7 @@ func getSanitizationPolicy() *bluemonday.Policy {
|
||||||
policy.AllowAttrs("target").OnElements("a")
|
policy.AllowAttrs("target").OnElements("a")
|
||||||
policy.AllowAttrs("title").OnElements("abbr")
|
policy.AllowAttrs("title").OnElements("abbr")
|
||||||
policy.AllowAttrs("style", "class", "id").Globally()
|
policy.AllowAttrs("style", "class", "id").Globally()
|
||||||
|
policy.AllowElements("header", "footer")
|
||||||
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
|
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
|
||||||
return policy
|
return policy
|
||||||
}
|
}
|
||||||
|
@ -234,3 +253,29 @@ func shortPostDescription(content string) string {
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
|
return strings.TrimSpace(fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleRenderMarkdown(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if !IsJSON(r) {
|
||||||
|
return impart.HTTPError{Status: http.StatusUnsupportedMediaType, Message: "Markdown API only supports JSON requests"}
|
||||||
|
}
|
||||||
|
|
||||||
|
in := struct {
|
||||||
|
CollectionURL string `json:"collection_url"`
|
||||||
|
RawBody string `json:"raw_body"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
err := decoder.Decode(&in)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Couldn't parse markdown JSON request: %v", err)
|
||||||
|
return ErrBadJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
out := struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}{
|
||||||
|
Body: applyMarkdown([]byte(in.RawBody), in.CollectionURL, app.cfg),
|
||||||
|
}
|
||||||
|
|
||||||
|
return impart.WriteSuccess(w, out, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
177
posts.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -16,6 +16,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -35,7 +36,6 @@ import (
|
||||||
"github.com/writeas/web-core/i18n"
|
"github.com/writeas/web-core/i18n"
|
||||||
"github.com/writeas/web-core/log"
|
"github.com/writeas/web-core/log"
|
||||||
"github.com/writeas/web-core/tags"
|
"github.com/writeas/web-core/tags"
|
||||||
"github.com/writeas/writefreely/config"
|
|
||||||
"github.com/writeas/writefreely/page"
|
"github.com/writeas/writefreely/page"
|
||||||
"github.com/writeas/writefreely/parse"
|
"github.com/writeas/writefreely/parse"
|
||||||
)
|
)
|
||||||
|
@ -63,6 +63,7 @@ type (
|
||||||
Description string
|
Description string
|
||||||
Author string
|
Author string
|
||||||
Views int64
|
Views int64
|
||||||
|
Images []string
|
||||||
IsPlainText bool
|
IsPlainText bool
|
||||||
IsCode bool
|
IsCode bool
|
||||||
IsLinkable bool
|
IsLinkable bool
|
||||||
|
@ -134,6 +135,7 @@ type (
|
||||||
Views int64
|
Views int64
|
||||||
Font string
|
Font string
|
||||||
Created time.Time
|
Created time.Time
|
||||||
|
Updated time.Time
|
||||||
IsRTL sql.NullBool
|
IsRTL sql.NullBool
|
||||||
Language sql.NullString
|
Language sql.NullString
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
|
@ -229,6 +231,10 @@ func (p Post) Summary() string {
|
||||||
return shortPostDescription(p.Content)
|
return shortPostDescription(p.Content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p Post) SummaryHTML() template.HTML {
|
||||||
|
return template.HTML(p.Summary())
|
||||||
|
}
|
||||||
|
|
||||||
// Excerpt shows any text that comes before a (more) tag.
|
// Excerpt shows any text that comes before a (more) tag.
|
||||||
// TODO: use HTMLExcerpt in templates instead of this method
|
// TODO: use HTMLExcerpt in templates instead of this method
|
||||||
func (p *Post) Excerpt() template.HTML {
|
func (p *Post) Excerpt() template.HTML {
|
||||||
|
@ -378,13 +384,16 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
if !isRaw {
|
if !isRaw {
|
||||||
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
|
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
|
||||||
|
post.Images = extractImages(post.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(ownerID.Int64)
|
var silenced bool
|
||||||
|
if found {
|
||||||
|
silenced, err = app.db.IsUserSilenced(ownerID.Int64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view post: %v", err)
|
log.Error("view post: %v", err)
|
||||||
return ErrInternalGeneral
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if post has been unpublished
|
// Check if post has been unpublished
|
||||||
|
@ -437,7 +446,7 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
Username string
|
Username string
|
||||||
IsOwner bool
|
IsOwner bool
|
||||||
SiteURL string
|
SiteURL string
|
||||||
Suspended bool
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
AnonymousPost: post,
|
AnonymousPost: post,
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
|
@ -448,10 +457,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
|
page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
if !page.IsOwner && suspended {
|
if !page.IsOwner && silenced {
|
||||||
return ErrPostNotFound
|
return ErrPostNotFound
|
||||||
}
|
}
|
||||||
page.Suspended = suspended
|
page.Silenced = silenced
|
||||||
err = templates["post"].ExecuteTemplate(w, "post", page)
|
err = templates["post"].ExecuteTemplate(w, "post", page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Post template execute error: %v", err)
|
log.Error("Post template execute error: %v", err)
|
||||||
|
@ -508,13 +517,12 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
} else {
|
} else {
|
||||||
userID = app.db.GetUserID(accessToken)
|
userID = app.db.GetUserID(accessToken)
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(userID)
|
silenced, err := app.db.IsUserSilenced(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("new post: %v", err)
|
log.Error("new post: %v", err)
|
||||||
return ErrInternalGeneral
|
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrUserSuspended
|
return ErrUserSilenced
|
||||||
}
|
}
|
||||||
|
|
||||||
if userID == -1 {
|
if userID == -1 {
|
||||||
|
@ -682,13 +690,12 @@ func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(userID)
|
silenced, err := app.db.IsUserSilenced(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("existing post: %v", err)
|
log.Error("existing post: %v", err)
|
||||||
return ErrInternalGeneral
|
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrUserSuspended
|
return ErrUserSilenced
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modify post struct
|
// Modify post struct
|
||||||
|
@ -885,13 +892,12 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
ownerID = u.ID
|
ownerID = u.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(ownerID)
|
silenced, err := app.db.IsUserSilenced(ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("add post: %v", err)
|
log.Error("add post: %v", err)
|
||||||
return ErrInternalGeneral
|
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrUserSuspended
|
return ErrUserSilenced
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse claimed posts in format:
|
// Parse claimed posts in format:
|
||||||
|
@ -988,13 +994,12 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
userID = u.ID
|
userID = u.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(userID)
|
silenced, err := app.db.IsUserSilenced(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("pin post: %v", err)
|
log.Error("pin post: %v", err)
|
||||||
return ErrInternalGeneral
|
|
||||||
}
|
}
|
||||||
if suspended {
|
if silenced {
|
||||||
return ErrUserSuspended
|
return ErrUserSilenced
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse request
|
// Parse request
|
||||||
|
@ -1039,7 +1044,6 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
var collID int64
|
var collID int64
|
||||||
var ownerID int64
|
|
||||||
var coll *Collection
|
var coll *Collection
|
||||||
var err error
|
var err error
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
|
@ -1049,26 +1053,33 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
coll.hostName = app.cfg.App.Host
|
|
||||||
_, err = apiCheckCollectionPermissions(app, r, coll)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
collID = coll.ID
|
collID = coll.ID
|
||||||
ownerID = coll.OwnerID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := app.db.GetPost(vars["post"], collID)
|
p, err := app.db.GetPost(vars["post"], collID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
suspended, err := app.db.IsUserSuspended(ownerID)
|
if coll == nil && p.CollectionID.Valid {
|
||||||
|
// Collection post is getting fetched by post ID, not coll alias + post slug, so get coll info now.
|
||||||
|
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("fetch post: %v", err)
|
return err
|
||||||
return ErrInternalGeneral
|
}
|
||||||
|
}
|
||||||
|
if coll != nil {
|
||||||
|
coll.hostName = app.cfg.App.Host
|
||||||
|
_, err = apiCheckCollectionPermissions(app, r, coll)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if suspended {
|
silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("fetch post: %v", err)
|
||||||
|
}
|
||||||
|
if silenced {
|
||||||
return ErrPostNotFound
|
return ErrPostNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1076,13 +1087,6 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
|
|
||||||
accept := r.Header.Get("Accept")
|
accept := r.Header.Get("Accept")
|
||||||
if strings.Contains(accept, "application/activity+json") {
|
if strings.Contains(accept, "application/activity+json") {
|
||||||
// Fetch information about the collection this belongs to
|
|
||||||
if coll == nil && p.CollectionID.Valid {
|
|
||||||
coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if coll == nil {
|
if coll == nil {
|
||||||
// This is a draft post; 404 for now
|
// This is a draft post; 404 for now
|
||||||
// TODO: return ActivityObject
|
// TODO: return ActivityObject
|
||||||
|
@ -1090,8 +1094,9 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
p.Collection = &CollectionObj{Collection: *coll}
|
p.Collection = &CollectionObj{Collection: *coll}
|
||||||
po := p.ActivityObject(app.cfg)
|
po := p.ActivityObject(app)
|
||||||
po.Context = []interface{}{activitystreams.Namespace}
|
po.Context = []interface{}{activitystreams.Namespace}
|
||||||
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, po, http.StatusOK)
|
return impart.RenderActivityJSON(w, po, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1125,7 +1130,8 @@ func (p *PublicPost) CanonicalURL(hostName string) string {
|
||||||
return p.Collection.CanonicalURL() + p.Slug.String
|
return p.Collection.CanonicalURL() + p.Slug.String
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object {
|
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
|
||||||
|
cfg := app.cfg
|
||||||
var o *activitystreams.Object
|
var o *activitystreams.Object
|
||||||
if strings.Index(p.Content, "\n\n") == -1 {
|
if strings.Index(p.Content, "\n\n") == -1 {
|
||||||
o = activitystreams.NewNoteObject()
|
o = activitystreams.NewNoteObject()
|
||||||
|
@ -1140,6 +1146,7 @@ func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object
|
||||||
p.Collection.FederatedAccount() + "/followers",
|
p.Collection.FederatedAccount() + "/followers",
|
||||||
}
|
}
|
||||||
o.Name = p.DisplayTitle()
|
o.Name = p.DisplayTitle()
|
||||||
|
p.augmentContent()
|
||||||
if p.HTMLContent == template.HTML("") {
|
if p.HTMLContent == template.HTML("") {
|
||||||
p.formatContent(cfg, false)
|
p.formatContent(cfg, false)
|
||||||
}
|
}
|
||||||
|
@ -1170,6 +1177,26 @@ func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Find mentioned users
|
||||||
|
mentionedUsers := make(map[string]string)
|
||||||
|
|
||||||
|
stripper := bluemonday.StrictPolicy()
|
||||||
|
content := stripper.Sanitize(p.Content)
|
||||||
|
mentions := mentionReg.FindAllString(content, -1)
|
||||||
|
|
||||||
|
for _, handle := range mentions {
|
||||||
|
actorIRI, err := app.db.GetProfilePageFromHandle(app, handle)
|
||||||
|
if err != nil {
|
||||||
|
log.Info("Couldn't find user '%s' locally or remotely", handle)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mentionedUsers[handle] = actorIRI
|
||||||
|
}
|
||||||
|
|
||||||
|
for handle, iri := range mentionedUsers {
|
||||||
|
o.CC = append(o.CC, iri)
|
||||||
|
o.Tag = append(o.Tag, activitystreams.Tag{Type: "Mention", HRef: iri, Name: handle})
|
||||||
|
}
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1220,9 +1247,9 @@ func getRawPost(app *App, friendlyID string) *RawPost {
|
||||||
var isRTL sql.NullBool
|
var isRTL sql.NullBool
|
||||||
var lang sql.NullString
|
var lang sql.NullString
|
||||||
var ownerID sql.NullInt64
|
var ownerID sql.NullInt64
|
||||||
var created time.Time
|
var created, updated time.Time
|
||||||
|
|
||||||
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID)
|
err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, updated, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &updated, &ownerID)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
return &RawPost{Content: "", Found: false, Gone: false}
|
return &RawPost{Content: "", Found: false, Gone: false}
|
||||||
|
@ -1230,7 +1257,7 @@ func getRawPost(app *App, friendlyID string) *RawPost {
|
||||||
return &RawPost{Content: "", Found: true, Gone: false}
|
return &RawPost{Content: "", Found: true, Gone: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
|
return &RawPost{Title: title, Content: content, Font: font, Created: created, Updated: updated, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1239,15 +1266,15 @@ func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
|
||||||
var id, title, content, font string
|
var id, title, content, font string
|
||||||
var isRTL sql.NullBool
|
var isRTL sql.NullBool
|
||||||
var lang sql.NullString
|
var lang sql.NullString
|
||||||
var created time.Time
|
var created, updated time.Time
|
||||||
var ownerID null.Int
|
var ownerID null.Int
|
||||||
var views int64
|
var views int64
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if app.cfg.App.SingleUser {
|
if app.cfg.App.SingleUser {
|
||||||
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
|
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
|
||||||
} else {
|
} else {
|
||||||
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
|
err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, updated, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &updated, &ownerID)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
|
@ -1263,6 +1290,7 @@ func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
|
||||||
Content: content,
|
Content: content,
|
||||||
Font: font,
|
Font: font,
|
||||||
Created: created,
|
Created: created,
|
||||||
|
Updated: updated,
|
||||||
IsRTL: isRTL,
|
IsRTL: isRTL,
|
||||||
Language: lang,
|
Language: lang,
|
||||||
OwnerID: ownerID.Int64,
|
OwnerID: ownerID.Int64,
|
||||||
|
@ -1337,19 +1365,22 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
|
||||||
}
|
}
|
||||||
c.hostName = app.cfg.App.Host
|
c.hostName = app.cfg.App.Host
|
||||||
|
|
||||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("view collection post: %v", err)
|
log.Error("view collection post: %v", err)
|
||||||
return ErrInternalGeneral
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check collection permissions
|
// Check collection permissions
|
||||||
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
|
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
|
||||||
return ErrPostNotFound
|
return ErrPostNotFound
|
||||||
}
|
}
|
||||||
if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
|
if c.IsProtected() && (u == nil || u.ID != c.OwnerID) {
|
||||||
|
if silenced {
|
||||||
|
return ErrPostNotFound
|
||||||
|
} else if !isAuthorizedForCollection(app, c.Alias, r) {
|
||||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
|
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cr.isCollOwner = u != nil && c.OwnerID == u.ID
|
cr.isCollOwner = u != nil && c.OwnerID == u.ID
|
||||||
|
|
||||||
|
@ -1359,7 +1390,7 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
|
||||||
|
|
||||||
// Fetch extra data about the Collection
|
// Fetch extra data about the Collection
|
||||||
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
|
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
|
||||||
coll := &CollectionObj{Collection: *c}
|
coll := NewCollectionObj(c)
|
||||||
owner, err := app.db.GetUserByID(coll.OwnerID)
|
owner, err := app.db.GetUserByID(coll.OwnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log the error and just continue
|
// Log the error and just continue
|
||||||
|
@ -1399,7 +1430,7 @@ Are you sure it was ever here?`,
|
||||||
p.Collection = coll
|
p.Collection = coll
|
||||||
p.IsTopLevel = app.cfg.App.SingleUser
|
p.IsTopLevel = app.cfg.App.SingleUser
|
||||||
|
|
||||||
if !p.IsOwner && suspended {
|
if !p.IsOwner && silenced {
|
||||||
return ErrPostNotFound
|
return ErrPostNotFound
|
||||||
}
|
}
|
||||||
// Check if post has been unpublished
|
// Check if post has been unpublished
|
||||||
|
@ -1407,6 +1438,8 @@ Are you sure it was ever here?`,
|
||||||
return impart.HTTPError{http.StatusGone, "Post was unpublished."}
|
return impart.HTTPError{http.StatusGone, "Post was unpublished."}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.augmentContent()
|
||||||
|
|
||||||
// Serve collection post
|
// Serve collection post
|
||||||
if isRaw {
|
if isRaw {
|
||||||
contentType := "text/plain"
|
contentType := "text/plain"
|
||||||
|
@ -1433,8 +1466,9 @@ Are you sure it was ever here?`,
|
||||||
return ErrCollectionPageNotFound
|
return ErrCollectionPageNotFound
|
||||||
}
|
}
|
||||||
p.extractData()
|
p.extractData()
|
||||||
ap := p.ActivityObject(app.cfg)
|
ap := p.ActivityObject(app)
|
||||||
ap.Context = []interface{}{activitystreams.Namespace}
|
ap.Context = []interface{}{activitystreams.Namespace}
|
||||||
|
setCacheControl(w, apCacheTime)
|
||||||
return impart.RenderActivityJSON(w, ap, http.StatusOK)
|
return impart.RenderActivityJSON(w, ap, http.StatusOK)
|
||||||
} else {
|
} else {
|
||||||
p.extractData()
|
p.extractData()
|
||||||
|
@ -1451,14 +1485,14 @@ Are you sure it was ever here?`,
|
||||||
IsFound bool
|
IsFound bool
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
CanInvite bool
|
CanInvite bool
|
||||||
Suspended bool
|
Silenced bool
|
||||||
}{
|
}{
|
||||||
PublicPost: p,
|
PublicPost: p,
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
IsOwner: cr.isCollOwner,
|
IsOwner: cr.isCollOwner,
|
||||||
IsCustomDomain: cr.isCustomDomain,
|
IsCustomDomain: cr.isCustomDomain,
|
||||||
IsFound: postFound,
|
IsFound: postFound,
|
||||||
Suspended: suspended,
|
Silenced: silenced,
|
||||||
}
|
}
|
||||||
tp.IsAdmin = u != nil && u.IsAdmin()
|
tp.IsAdmin = u != nil && u.IsAdmin()
|
||||||
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
|
tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
|
||||||
|
@ -1519,22 +1553,39 @@ func (rp *RawPost) Created8601() string {
|
||||||
return rp.Created.Format("2006-01-02T15:04:05Z")
|
return rp.Created.Format("2006-01-02T15:04:05Z")
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
|
func (rp *RawPost) Updated8601() string {
|
||||||
|
if rp.Updated.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return rp.Updated.Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageURLRegex = regexp.MustCompile(`(?i)[^ ]+\.(gif|png|jpg|jpeg|image)$`)
|
||||||
|
|
||||||
func (p *Post) extractImages() {
|
func (p *Post) extractImages() {
|
||||||
matches := extract.ExtractUrls(p.Content)
|
p.Images = extractImages(p.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractImages(content string) []string {
|
||||||
|
matches := extract.ExtractUrls(content)
|
||||||
urls := map[string]bool{}
|
urls := map[string]bool{}
|
||||||
for i := range matches {
|
for i := range matches {
|
||||||
u := matches[i].Text
|
uRaw := matches[i].Text
|
||||||
if !imageURLRegex.MatchString(u) {
|
// Parse the extracted text so we can examine the path
|
||||||
|
u, err := url.Parse(uRaw)
|
||||||
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
urls[u] = true
|
// Ensure the path looks like it leads to an image file
|
||||||
|
if !imageURLRegex.MatchString(u.Path) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
urls[uRaw] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
resURLs := make([]string, 0)
|
resURLs := make([]string, 0)
|
||||||
for k := range urls {
|
for k := range urls {
|
||||||
resURLs = append(resURLs, k)
|
resURLs = append(resURLs, k)
|
||||||
}
|
}
|
||||||
p.Images = resURLs
|
return resURLs
|
||||||
}
|
}
|
||||||
|
|
16
read.go
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||||
*
|
*
|
||||||
* This file is part of WriteFreely.
|
* This file is part of WriteFreely.
|
||||||
*
|
*
|
||||||
|
@ -33,6 +33,8 @@ const (
|
||||||
tlAPIPageLimit = 10
|
tlAPIPageLimit = 10
|
||||||
tlMaxAuthorPosts = 5
|
tlMaxAuthorPosts = 5
|
||||||
tlPostsPerPage = 16
|
tlPostsPerPage = 16
|
||||||
|
tlMaxPostCache = 250
|
||||||
|
tlCacheDur = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type localTimeline struct {
|
type localTimeline struct {
|
||||||
|
@ -60,19 +62,25 @@ type readPublication struct {
|
||||||
func initLocalTimeline(app *App) {
|
func initLocalTimeline(app *App) {
|
||||||
app.timeline = &localTimeline{
|
app.timeline = &localTimeline{
|
||||||
postsPerPage: tlPostsPerPage,
|
postsPerPage: tlPostsPerPage,
|
||||||
m: memo.New(app.FetchPublicPosts, 10*time.Minute),
|
m: memo.New(app.FetchPublicPosts, tlCacheDur),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// satisfies memo.Func
|
// satisfies memo.Func
|
||||||
func (app *App) FetchPublicPosts() (interface{}, error) {
|
func (app *App) FetchPublicPosts() (interface{}, error) {
|
||||||
|
// Conditions
|
||||||
|
limit := fmt.Sprintf("LIMIT %d", tlMaxPostCache)
|
||||||
|
// This is better than the hard limit when limiting posts from individual authors
|
||||||
|
// ageCond := `p.created >= ` + app.db.dateSub(3, "month") + ` AND `
|
||||||
|
|
||||||
// Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months
|
// Finds all public posts and posts in a public collection published during the owner's active subscription period and within the last 3 months
|
||||||
rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated
|
rows, err := app.db.Query(`SELECT p.id, alias, c.title, p.slug, p.title, p.content, p.text_appearance, p.language, p.rtl, p.created, p.updated
|
||||||
FROM collections c
|
FROM collections c
|
||||||
LEFT JOIN posts p ON p.collection_id = c.id
|
LEFT JOIN posts p ON p.collection_id = c.id
|
||||||
LEFT JOIN users u ON u.id = p.owner_id
|
LEFT JOIN users u ON u.id = p.owner_id
|
||||||
WHERE c.privacy = 1 AND (p.created >= ` + app.db.dateSub(3, "month") + ` AND p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
|
WHERE c.privacy = 1 AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
|
||||||
ORDER BY p.created DESC`)
|
ORDER BY p.created DESC
|
||||||
|
` + limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed selecting from posts: %v", err)
|
log.Error("Failed selecting from posts: %v", err)
|
||||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()}
|
return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts." + err.Error()}
|
||||||
|
|
28
routes.go
|
@ -70,6 +70,15 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
|
write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
|
||||||
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
|
write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
|
||||||
|
|
||||||
|
// handle mentions
|
||||||
|
write.HandleFunc("/@/{handle}", handler.Web(handleViewMention, UserLevelReader))
|
||||||
|
|
||||||
|
configureSlackOauth(handler, write, apper.App())
|
||||||
|
configureWriteAsOauth(handler, write, apper.App())
|
||||||
|
configureGitlabOauth(handler, write, apper.App())
|
||||||
|
configureGenericOauth(handler, write, apper.App())
|
||||||
|
configureGiteaOauth(handler, write, apper.App())
|
||||||
|
|
||||||
// Set up dyamic page handlers
|
// Set up dyamic page handlers
|
||||||
// Handle auth
|
// Handle auth
|
||||||
auth := write.PathPrefix("/api/auth/").Subrouter()
|
auth := write.PathPrefix("/api/auth/").Subrouter()
|
||||||
|
@ -94,6 +103,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
|
me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
|
||||||
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
|
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
|
||||||
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
|
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
|
||||||
|
me.HandleFunc("/import", handler.User(viewImport)).Methods("GET")
|
||||||
me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
|
me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
|
||||||
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
|
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
|
||||||
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
|
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
|
||||||
|
@ -106,10 +116,14 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
|
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
|
||||||
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
|
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
|
||||||
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
|
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
|
||||||
|
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
|
||||||
|
apiMe.HandleFunc("/oauth/remove", handler.User(removeOauth)).Methods("POST")
|
||||||
|
|
||||||
// Sign up validation
|
// Sign up validation
|
||||||
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
|
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
|
||||||
|
|
||||||
|
write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST")
|
||||||
|
|
||||||
// Handle collections
|
// Handle collections
|
||||||
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
|
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
|
||||||
apiColls := write.PathPrefix("/api/collections/").Subrouter()
|
apiColls := write.PathPrefix("/api/collections/").Subrouter()
|
||||||
|
@ -142,6 +156,8 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
|
||||||
|
|
||||||
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
|
write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
|
||||||
|
write.HandleFunc("/admin/monitor", handler.Admin(handleViewAdminMonitor)).Methods("GET")
|
||||||
|
write.HandleFunc("/admin/settings", handler.Admin(handleViewAdminSettings)).Methods("GET")
|
||||||
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
|
write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
|
||||||
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
|
write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
|
||||||
write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
|
write.HandleFunc("/admin/user/{username}/status", handler.Admin(handleAdminToggleUserStatus)).Methods("POST")
|
||||||
|
@ -150,11 +166,12 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
|
write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
|
||||||
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
|
write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
|
||||||
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
|
write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
|
||||||
|
write.HandleFunc("/admin/updates", handler.Admin(handleViewAdminUpdates)).Methods("GET")
|
||||||
|
|
||||||
// Handle special pages first
|
// Handle special pages first
|
||||||
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
|
write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
|
||||||
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
|
write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
|
||||||
write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
|
write.HandleFunc("/invite/{code:[a-zA-Z0-9]+}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
|
||||||
// TODO: show a reader-specific 404 page if the function is disabled
|
// TODO: show a reader-specific 404 page if the function is disabled
|
||||||
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
|
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
|
||||||
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
|
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
|
||||||
|
@ -162,14 +179,14 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
draftEditPrefix := ""
|
draftEditPrefix := ""
|
||||||
if apper.App().cfg.App.SingleUser {
|
if apper.App().cfg.App.SingleUser {
|
||||||
draftEditPrefix = "/d"
|
draftEditPrefix = "/d"
|
||||||
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
|
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
|
||||||
} else {
|
} else {
|
||||||
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
|
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
// All the existing stuff
|
// All the existing stuff
|
||||||
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
|
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
|
||||||
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET")
|
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET")
|
||||||
// Collections
|
// Collections
|
||||||
if apper.App().cfg.App.SingleUser {
|
if apper.App().cfg.App.SingleUser {
|
||||||
RouteCollections(handler, write.PathPrefix("/").Subrouter())
|
RouteCollections(handler, write.PathPrefix("/").Subrouter())
|
||||||
|
@ -181,6 +198,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
||||||
}
|
}
|
||||||
write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
|
write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
|
||||||
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
|
write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Copyright © 2020 A Bunch Tell 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.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
#
|
||||||
|
# WriteFreely CSS invalidation script
|
||||||
|
#
|
||||||
|
# usage: ./invalidate-css.sh <build-directory>
|
||||||
|
#
|
||||||
|
# This script provides an automated way to invalidate stylesheets cached in the
|
||||||
|
# browser. It uses the last git commit hashes of the most frequently modified
|
||||||
|
# LESS files in the project and appends them to the stylesheet `href` in all
|
||||||
|
# template files.
|
||||||
|
#
|
||||||
|
# This is designed to be used when building a WriteFreely release.
|
||||||
|
#
|
||||||
|
###############################################################################
|
||||||
|
|
||||||
|
# Get parent build directory from first argument
|
||||||
|
buildDir=$1
|
||||||
|
|
||||||
|
# Get short hash of each primary LESS file's last commit
|
||||||
|
cssHash=$(git log -n 1 --pretty=format:%h -- less/core.less)
|
||||||
|
cssNewHash=$(git log -n 1 --pretty=format:%h -- less/new-core.less)
|
||||||
|
cssPadHash=$(git log -n 1 --pretty=format:%h -- less/pad.less)
|
||||||
|
|
||||||
|
echo "Adding write.css version ($cssHash $cssNewHash $cssPadHash) to .tmpl files..."
|
||||||
|
cd "$buildDir/templates" || exit 1
|
||||||
|
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/write.css/write.css?${cssHash}${cssNewHash}${cssPadHash}/g"
|
||||||
|
find . -type f -name "*.tmpl" -print0 | xargs -0 sed -i "s/{{.Theme}}.css/{{.Theme}}.css?${cssHash}${cssNewHash}${cssPadHash}/g"
|
|
@ -11,7 +11,7 @@
|
||||||
## have not installed the binary `writefreely` in another location. ##
|
## have not installed the binary `writefreely` in another location. ##
|
||||||
###############################################################################
|
###############################################################################
|
||||||
#
|
#
|
||||||
# Copyright © 2019 A Bunch Tell LLC.
|
# Copyright © 2019-2020 A Bunch Tell LLC.
|
||||||
#
|
#
|
||||||
# This file is part of WriteFreely.
|
# This file is part of WriteFreely.
|
||||||
#
|
#
|
||||||
|
@ -31,7 +31,7 @@ fi
|
||||||
# go ahead and check for the latest release on linux
|
# go ahead and check for the latest release on linux
|
||||||
echo "Checking for updates..."
|
echo "Checking for updates..."
|
||||||
|
|
||||||
url=`curl -s https://api.github.com/repos/writeas/writefreely/releases/latest | grep 'browser_' | grep linux | cut -d\" -f4`
|
url=`curl -s https://api.github.com/repos/writeas/writefreely/releases/latest | grep 'browser_' | grep 'linux' | grep 'amd64' | cut -d\" -f4`
|
||||||
|
|
||||||
# check current version
|
# check current version
|
||||||
|
|
||||||
|
@ -82,13 +82,25 @@ filename=${parts[-1]}
|
||||||
echo "Extracting files..."
|
echo "Extracting files..."
|
||||||
tar -zxf $tempdir/$filename -C $tempdir
|
tar -zxf $tempdir/$filename -C $tempdir
|
||||||
|
|
||||||
|
# stop service
|
||||||
|
echo "Stopping writefreely systemd service..."
|
||||||
|
if `systemctl start writefreely`; then
|
||||||
|
echo "Success, service stopped."
|
||||||
|
else
|
||||||
|
echo "Upgrade failed to stop the systemd service, exiting early."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# copy files
|
# copy files
|
||||||
echo "Copying files..."
|
echo "Copying files..."
|
||||||
cp -r $tempdir/{pages,static,templates,writefreely} .
|
cp -r $tempdir/writefreely/{pages,static,templates,writefreely} .
|
||||||
|
|
||||||
|
# migrate db
|
||||||
|
./writefreely -migrate
|
||||||
|
|
||||||
# restart service
|
# restart service
|
||||||
echo "Restarting writefreely systemd service..."
|
echo "Starting writefreely systemd service..."
|
||||||
if `systemctl restart writefreely`; then
|
if `systemctl start writefreely`; then
|
||||||
echo "Success, version has been upgraded to $latest."
|
echo "Success, version has been upgraded to $latest."
|
||||||
else
|
else
|
||||||
echo "Upgrade complete, but failed to restart service."
|
echo "Upgrade complete, but failed to restart service."
|
||||||
|
|
|
@ -0,0 +1,315 @@
|
||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
// Package semver implements comparison of semantic version strings.
|
||||||
|
// In this package, semantic version strings must begin with a leading "v",
|
||||||
|
// as in "v1.0.0".
|
||||||
|
//
|
||||||
|
// The general form of a semantic version string accepted by this package is
|
||||||
|
//
|
||||||
|
// vMAJOR[.MINOR[.PATCH[-PRERELEASE][+BUILD]]]
|
||||||
|
//
|
||||||
|
// where square brackets indicate optional parts of the syntax;
|
||||||
|
// MAJOR, MINOR, and PATCH are decimal integers without extra leading zeros;
|
||||||
|
// PRERELEASE and BUILD are each a series of non-empty dot-separated identifiers
|
||||||
|
// using only alphanumeric characters and hyphens; and
|
||||||
|
// all-numeric PRERELEASE identifiers must not have leading zeros.
|
||||||
|
//
|
||||||
|
// This package follows Semantic Versioning 2.0.0 (see semver.org)
|
||||||
|
// with two exceptions. First, it requires the "v" prefix. Second, it recognizes
|
||||||
|
// vMAJOR and vMAJOR.MINOR (with no prerelease or build suffixes)
|
||||||
|
// as shorthands for vMAJOR.0.0 and vMAJOR.MINOR.0.
|
||||||
|
|
||||||
|
// Package writefreely
|
||||||
|
// copied from
|
||||||
|
// https://github.com/golang/tools/blob/master/internal/semver/semver.go
|
||||||
|
// slight modifications made
|
||||||
|
package writefreely
|
||||||
|
|
||||||
|
// parsed returns the parsed form of a semantic version string.
|
||||||
|
type parsed struct {
|
||||||
|
major string
|
||||||
|
minor string
|
||||||
|
patch string
|
||||||
|
short string
|
||||||
|
prerelease string
|
||||||
|
build string
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValid reports whether v is a valid semantic version string.
|
||||||
|
func IsValid(v string) bool {
|
||||||
|
_, ok := semParse(v)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompareSemver returns an integer comparing two versions according to
|
||||||
|
// according to semantic version precedence.
|
||||||
|
// The result will be 0 if v == w, -1 if v < w, or +1 if v > w.
|
||||||
|
//
|
||||||
|
// An invalid semantic version string is considered less than a valid one.
|
||||||
|
// All invalid semantic version strings compare equal to each other.
|
||||||
|
func CompareSemver(v, w string) int {
|
||||||
|
pv, ok1 := semParse(v)
|
||||||
|
pw, ok2 := semParse(w)
|
||||||
|
if !ok1 && !ok2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if !ok1 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if !ok2 {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
if c := compareInt(pv.major, pw.major); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if c := compareInt(pv.minor, pw.minor); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if c := compareInt(pv.patch, pw.patch); c != 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return comparePrerelease(pv.prerelease, pw.prerelease)
|
||||||
|
}
|
||||||
|
|
||||||
|
func semParse(v string) (p parsed, ok bool) {
|
||||||
|
if v == "" || v[0] != 'v' {
|
||||||
|
p.err = "missing v prefix"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.major, v, ok = parseInt(v[1:])
|
||||||
|
if !ok {
|
||||||
|
p.err = "bad major version"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
p.minor = "0"
|
||||||
|
p.patch = "0"
|
||||||
|
p.short = ".0.0"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v[0] != '.' {
|
||||||
|
p.err = "bad minor prefix"
|
||||||
|
ok = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.minor, v, ok = parseInt(v[1:])
|
||||||
|
if !ok {
|
||||||
|
p.err = "bad minor version"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
p.patch = "0"
|
||||||
|
p.short = ".0"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v[0] != '.' {
|
||||||
|
p.err = "bad patch prefix"
|
||||||
|
ok = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.patch, v, ok = parseInt(v[1:])
|
||||||
|
if !ok {
|
||||||
|
p.err = "bad patch version"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(v) > 0 && v[0] == '-' {
|
||||||
|
p.prerelease, v, ok = parsePrerelease(v)
|
||||||
|
if !ok {
|
||||||
|
p.err = "bad prerelease"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(v) > 0 && v[0] == '+' {
|
||||||
|
p.build, v, ok = parseBuild(v)
|
||||||
|
if !ok {
|
||||||
|
p.err = "bad build"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v != "" {
|
||||||
|
p.err = "junk on end"
|
||||||
|
ok = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(v string) (t, rest string, ok bool) {
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v[0] < '0' || '9' < v[0] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i := 1
|
||||||
|
for i < len(v) && '0' <= v[i] && v[i] <= '9' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if v[0] == '0' && i != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return v[:i], v[i:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePrerelease(v string) (t, rest string, ok bool) {
|
||||||
|
// "A pre-release version MAY be denoted by appending a hyphen and
|
||||||
|
// a series of dot separated identifiers immediately following the patch version.
|
||||||
|
// Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-].
|
||||||
|
// Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes."
|
||||||
|
if v == "" || v[0] != '-' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i := 1
|
||||||
|
start := 1
|
||||||
|
for i < len(v) && v[i] != '+' {
|
||||||
|
if !isIdentChar(v[i]) && v[i] != '.' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v[i] == '.' {
|
||||||
|
if start == i || isBadNum(v[start:i]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if start == i || isBadNum(v[start:i]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return v[:i], v[i:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBuild(v string) (t, rest string, ok bool) {
|
||||||
|
if v == "" || v[0] != '+' {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i := 1
|
||||||
|
start := 1
|
||||||
|
for i < len(v) {
|
||||||
|
if !isIdentChar(v[i]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v[i] == '.' {
|
||||||
|
if start == i {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if start == i {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return v[:i], v[i:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIdentChar(c byte) bool {
|
||||||
|
return 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBadNum(v string) bool {
|
||||||
|
i := 0
|
||||||
|
for i < len(v) && '0' <= v[i] && v[i] <= '9' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i == len(v) && i > 1 && v[0] == '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNum(v string) bool {
|
||||||
|
i := 0
|
||||||
|
for i < len(v) && '0' <= v[i] && v[i] <= '9' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i == len(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareInt(x, y string) int {
|
||||||
|
if x == y {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if len(x) < len(y) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if len(x) > len(y) {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
if x < y {
|
||||||
|
return -1
|
||||||
|
} else {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparePrerelease(x, y string) int {
|
||||||
|
// "When major, minor, and patch are equal, a pre-release version has
|
||||||
|
// lower precedence than a normal version.
|
||||||
|
// Example: 1.0.0-alpha < 1.0.0.
|
||||||
|
// Precedence for two pre-release versions with the same major, minor,
|
||||||
|
// and patch version MUST be determined by comparing each dot separated
|
||||||
|
// identifier from left to right until a difference is found as follows:
|
||||||
|
// identifiers consisting of only digits are compared numerically and
|
||||||
|
// identifiers with letters or hyphens are compared lexically in ASCII
|
||||||
|
// sort order. Numeric identifiers always have lower precedence than
|
||||||
|
// non-numeric identifiers. A larger set of pre-release fields has a
|
||||||
|
// higher precedence than a smaller set, if all of the preceding
|
||||||
|
// identifiers are equal.
|
||||||
|
// Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta <
|
||||||
|
// 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0."
|
||||||
|
if x == y {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if x == "" {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
if y == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
for x != "" && y != "" {
|
||||||
|
x = x[1:] // skip - or .
|
||||||
|
y = y[1:] // skip - or .
|
||||||
|
var dx, dy string
|
||||||
|
dx, x = nextIdent(x)
|
||||||
|
dy, y = nextIdent(y)
|
||||||
|
if dx != dy {
|
||||||
|
ix := isNum(dx)
|
||||||
|
iy := isNum(dy)
|
||||||
|
if ix != iy {
|
||||||
|
if ix {
|
||||||
|
return -1
|
||||||
|
} else {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ix {
|
||||||
|
if len(dx) < len(dy) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if len(dx) > len(dy) {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dx < dy {
|
||||||
|
return -1
|
||||||
|
} else {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if x == "" {
|
||||||
|
return -1
|
||||||
|
} else {
|
||||||
|
return +1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextIdent(x string) (dx, rest string) {
|
||||||
|
i := 0
|
||||||
|
for i < len(x) && x[i] != '.' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return x[:i], x[i:]
|
||||||
|
}
|
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 1005 B |
After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 2.8 KiB |
After Width: | Height: | Size: 2.5 KiB |
After Width: | Height: | Size: 5.0 KiB |
|
@ -116,13 +116,27 @@ var H = {
|
||||||
save: function($el, key) {
|
save: function($el, key) {
|
||||||
localStorage.setItem(key, $el.el.value);
|
localStorage.setItem(key, $el.el.value);
|
||||||
},
|
},
|
||||||
load: function($el, key, onlyLoadPopulated) {
|
load: function($el, key, onlyLoadPopulated, postUpdated) {
|
||||||
var val = localStorage.getItem(key);
|
var val = localStorage.getItem(key);
|
||||||
if (onlyLoadPopulated && val == null) {
|
if (onlyLoadPopulated && val == null) {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
$el.el.value = val;
|
$el.el.value = val;
|
||||||
|
if (postUpdated != null) {
|
||||||
|
var lastLocalPublishStr = localStorage.getItem(key+'-published');
|
||||||
|
if (lastLocalPublishStr != null && lastLocalPublishStr != '') {
|
||||||
|
try {
|
||||||
|
var lastLocalPublish = new Date(lastLocalPublishStr);
|
||||||
|
if (postUpdated > lastLocalPublish) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("unable to parse draft updated time");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
set: function(key, value) {
|
set: function(key, value) {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
function toLocalDate(dateEl, displayEl) {
|
||||||
|
var d = new Date(dateEl.getAttribute("datetime"));
|
||||||
|
displayEl.textContent = d.toLocaleDateString(navigator.language || "en-US", { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust dates on individual post pages, and on posts in a list *with* an explicit title
|
||||||
|
var $dates = document.querySelectorAll("article > time");
|
||||||
|
for (var i=0; i < $dates.length; i++) {
|
||||||
|
toLocalDate($dates[i], $dates[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust dates on posts in a list without an explicit title, where they act as the header
|
||||||
|
$dates = document.querySelectorAll("h2.post-title > time");
|
||||||
|
for (i=0; i < $dates.length; i++) {
|
||||||
|
toLocalDate($dates[i], $dates[i].querySelector('a'));
|
||||||
|
}
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 419b0a6eee7eefc0f85e47f7d4f8227ec28b8e57
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
var menuItems = document.querySelectorAll('li.has-submenu');
|
||||||
|
var menuTimer;
|
||||||
|
function closeMenu($menu) {
|
||||||
|
$menu.querySelector('a').setAttribute('aria-expanded', "false");
|
||||||
|
$menu.className = "has-submenu";
|
||||||
|
}
|
||||||
|
Array.prototype.forEach.call(menuItems, function(el, i){
|
||||||
|
el.addEventListener("mouseover", function(event){
|
||||||
|
let $menu = document.querySelectorAll(".has-submenu.open");
|
||||||
|
if ($menu.length > 0) {
|
||||||
|
closeMenu($menu[0]);
|
||||||
|
}
|
||||||
|
this.className = "has-submenu open";
|
||||||
|
this.querySelector('a').setAttribute('aria-expanded', "true");
|
||||||
|
clearTimeout(menuTimer);
|
||||||
|
});
|
||||||
|
el.addEventListener("mouseout", function(event){
|
||||||
|
menuTimer = setTimeout(function(event){
|
||||||
|
let $menu = document.querySelector(".has-submenu.open");
|
||||||
|
closeMenu($menu);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
el.querySelector('a').addEventListener("click", function(event){
|
||||||
|
if (this.parentNode.className == "has-submenu") {
|
||||||
|
this.parentNode.className = "has-submenu open";
|
||||||
|
this.setAttribute('aria-expanded', "true");
|
||||||
|
} else {
|
||||||
|
this.parentNode.className = "has-submenu";
|
||||||
|
this.setAttribute('aria-expanded', "false");
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
38
templates.go
|
@ -11,6 +11,7 @@
|
||||||
package writefreely
|
package writefreely
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
@ -37,6 +38,10 @@ var (
|
||||||
"localstr": localStr,
|
"localstr": localStr,
|
||||||
"localhtml": localHTML,
|
"localhtml": localHTML,
|
||||||
"tolower": strings.ToLower,
|
"tolower": strings.ToLower,
|
||||||
|
"title": strings.Title,
|
||||||
|
"hasPrefix": strings.HasPrefix,
|
||||||
|
"hasSuffix": strings.HasSuffix,
|
||||||
|
"dict": dict,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -64,7 +69,7 @@ func initTemplate(parentDir, name string) {
|
||||||
filepath.Join(parentDir, templatesDir, name+".tmpl"),
|
filepath.Join(parentDir, templatesDir, name+".tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
|
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "base.tmpl"),
|
filepath.Join(parentDir, templatesDir, "base.tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"),
|
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
|
||||||
}
|
}
|
||||||
if name == "collection" || name == "collection-tags" || name == "chorus-collection" {
|
if name == "collection" || name == "collection-tags" || name == "chorus-collection" {
|
||||||
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
|
// These pages list out collection posts, so we also parse templatesDir + "include/posts.tmpl"
|
||||||
|
@ -84,12 +89,18 @@ func initPage(parentDir, path, key string) {
|
||||||
log.Info(" [%s] %s", key, path)
|
log.Info(" [%s] %s", key, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(
|
files := []string{
|
||||||
path,
|
path,
|
||||||
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
|
filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "base.tmpl"),
|
filepath.Join(parentDir, templatesDir, "base.tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"),
|
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
|
||||||
))
|
}
|
||||||
|
|
||||||
|
if key == "login.tmpl" || key == "landing.tmpl" || key == "signup.tmpl" {
|
||||||
|
files = append(files, filepath.Join(parentDir, templatesDir, "include", "oauth.tmpl"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func initUserPage(parentDir, path, key string) {
|
func initUserPage(parentDir, path, key string) {
|
||||||
|
@ -101,7 +112,8 @@ func initUserPage(parentDir, path, key string) {
|
||||||
path,
|
path,
|
||||||
filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
|
filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
|
filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
|
||||||
filepath.Join(parentDir, templatesDir, "user", "include", "suspended.tmpl"),
|
filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
|
||||||
|
filepath.Join(parentDir, templatesDir, "user", "include", "nav.tmpl"),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,3 +211,19 @@ func localHTML(term, lang string) template.HTML {
|
||||||
s = strings.Replace(s, "write.as", "<a href=\"https://writefreely.org\">writefreely</a>", 1)
|
s = strings.Replace(s, "write.as", "<a href=\"https://writefreely.org\">writefreely</a>", 1)
|
||||||
return template.HTML(s)
|
return template.HTML(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// from: https://stackoverflow.com/a/18276968/1549194
|
||||||
|
func dict(values ...interface{}) (map[string]interface{}, error) {
|
||||||
|
if len(values)%2 != 0 {
|
||||||
|
return nil, errors.New("dict: invalid number of parameters")
|
||||||
|
}
|
||||||
|
dict := make(map[string]interface{}, len(values)/2)
|
||||||
|
for i := 0; i < len(values); i += 2 {
|
||||||
|
key, ok := values[i].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("dict: keys must be strings")
|
||||||
|
}
|
||||||
|
dict[key] = values[i+1]
|
||||||
|
}
|
||||||
|
return dict, nil
|
||||||
|
}
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
|
|
||||||
{{end}}{{.Post.Content}}</textarea>
|
{{end}}{{.Post.Content}}</textarea>
|
||||||
|
|
||||||
|
<div class="alert success hidden" id="edited-elsewhere">This post has been updated elsewhere since you last published! <a href="#" id="erase-edit">Delete draft and reload</a>.</div>
|
||||||
|
|
||||||
<header id="tools">
|
<header id="tools">
|
||||||
<div id="clip">
|
<div id="clip">
|
||||||
{{if not .SingleUser}}<h1>{{if .Chorus}}<a href="/" title="Home">{{else}}<a href="/me/c/" title="View blogs">{{end}}{{.SiteName}}</a></h1>{{end}}
|
{{if not .SingleUser}}<h1>{{if .Chorus}}<a href="/" title="Home">{{else}}<a href="/me/c/" title="View blogs">{{end}}{{.SiteName}}</a></h1>{{end}}
|
||||||
|
@ -36,6 +38,7 @@
|
||||||
<script>
|
<script>
|
||||||
var $writer = H.getEl('writer');
|
var $writer = H.getEl('writer');
|
||||||
var $btnPublish = H.getEl('publish');
|
var $btnPublish = H.getEl('publish');
|
||||||
|
var $btnEraseEdit = H.getEl('edited-elsewhere');
|
||||||
var $wc = H.getEl("wc");
|
var $wc = H.getEl("wc");
|
||||||
var updateWordCount = function() {
|
var updateWordCount = function() {
|
||||||
var words = 0;
|
var words = 0;
|
||||||
|
@ -58,7 +61,17 @@
|
||||||
};
|
};
|
||||||
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
|
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
|
||||||
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
|
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
|
||||||
H.load($writer, draftDoc, true);
|
var updatedStr = '{{.Post.Updated8601}}';
|
||||||
|
var updated = null;
|
||||||
|
if (updatedStr != '') {
|
||||||
|
updated = new Date(updatedStr);
|
||||||
|
}
|
||||||
|
var ok = H.load($writer, draftDoc, true, updated);
|
||||||
|
if (!ok) {
|
||||||
|
// Show "edited elsewhere" warning
|
||||||
|
$btnEraseEdit.el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
var defaultTimeSet = false;
|
||||||
updateWordCount();
|
updateWordCount();
|
||||||
|
|
||||||
var typingTimer;
|
var typingTimer;
|
||||||
|
@ -130,6 +143,7 @@
|
||||||
data = JSON.parse(http.responseText);
|
data = JSON.parse(http.responseText);
|
||||||
id = data.data.id;
|
id = data.data.id;
|
||||||
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
|
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
|
||||||
|
localStorage.setItem('draft'+id+'-published', new Date().toISOString());
|
||||||
|
|
||||||
{{ if not .Post.Id }}
|
{{ if not .Post.Id }}
|
||||||
// Post created
|
// Post created
|
||||||
|
@ -198,6 +212,13 @@
|
||||||
publish(content, selectedFont);
|
publish(content, selectedFont);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
H.getEl('erase-edit').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
H.remove(draftDoc);
|
||||||
|
H.remove(draftDoc+'-published');
|
||||||
|
justPublished = true; // Block auto-save
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
WebFontConfig = {
|
WebFontConfig = {
|
||||||
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
|
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
|
||||||
|
@ -207,12 +228,20 @@
|
||||||
var doneTyping = function() {
|
var doneTyping = function() {
|
||||||
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
|
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
|
||||||
H.save($writer, draftDoc);
|
H.save($writer, draftDoc);
|
||||||
|
if (!defaultTimeSet) {
|
||||||
|
var lastLocalPublishStr = localStorage.getItem(draftDoc+'-published');
|
||||||
|
if (lastLocalPublishStr == null || lastLocalPublishStr == '') {
|
||||||
|
localStorage.setItem(draftDoc+'-published', updatedStr);
|
||||||
|
}
|
||||||
|
defaultTimeSet = true;
|
||||||
|
}
|
||||||
updateWordCount();
|
updateWordCount();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('beforeunload', function(e) {
|
window.addEventListener('beforeunload', function(e) {
|
||||||
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
|
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
|
||||||
H.remove(draftDoc);
|
H.remove(draftDoc);
|
||||||
|
H.remove(draftDoc+'-published');
|
||||||
} else if (!justPublished) {
|
} else if (!justPublished) {
|
||||||
doneTyping();
|
doneTyping();
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
<nav id="user-nav">
|
<nav id="user-nav">
|
||||||
{{if .Username}}
|
{{if .Username}}
|
||||||
<nav class="dropdown-nav">
|
<nav class="dropdown-nav">
|
||||||
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
|
<ul><li class="has-submenu"><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
|
||||||
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
|
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
|
||||||
<li><a href="/me/settings">Account settings</a></li>
|
<li><a href="/me/settings">Account settings</a></li>
|
||||||
<li><a href="/me/export">Export</a></li>
|
<li><a href="/me/export">Export</a></li>
|
||||||
|
@ -47,8 +47,8 @@
|
||||||
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
|
{{if not .DisableDrafts}}<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>{{end}}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
|
{{if and (and .LocalTimeline .CanViewReader) (not .Chorus)}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
|
||||||
{{if and (and (and .Chorus .OpenRegistration) (not .Username)) (or (not .Private) (ne .Landing ""))}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
|
{{if eq .SignupPath "/signup"}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</a>{{end}}
|
||||||
{{if not .Username}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{else if .SimpleNav}}<a href="/me/logout">Log out</a>{{end}}
|
{{if and (not .Username) (not .Private)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{else if .SimpleNav}}<a href="/me/logout">Log out</a>{{end}}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</nav>
|
</nav>
|
||||||
{{if .Chorus}}{{if .Username}}<div class="right-side" style="font-size: 0.86em;">
|
{{if .Chorus}}{{if .Username}}<div class="right-side" style="font-size: 0.86em;">
|
||||||
|
@ -67,6 +67,7 @@
|
||||||
{{ template "footer" . }}
|
{{ template "footer" . }}
|
||||||
|
|
||||||
{{if not .JSDisabled}}
|
{{if not .JSDisabled}}
|
||||||
|
<script type="text/javascript" src="/js/menu.js"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
{{if .WebFonts}}
|
{{if .WebFonts}}
|
||||||
try { // Google Fonts
|
try { // Google Fonts
|
||||||
|
|
|
@ -37,16 +37,6 @@ body footer {
|
||||||
}
|
}
|
||||||
body#post header {
|
body#post header {
|
||||||
padding: 1em 1rem;
|
padding: 1em 1rem;
|
||||||
}
|
|
||||||
article time.dt-published {
|
|
||||||
display: block;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
body#post article h2#title{
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
article time.dt-published {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
@ -65,10 +55,10 @@ article time.dt-published {
|
||||||
|
|
||||||
{{template "user-navigation" .}}
|
{{template "user-navigation" .}}
|
||||||
|
|
||||||
{{if .Suspended}}
|
{{if .Silenced}}
|
||||||
{{template "user-suspended"}}
|
{{template "user-silenced"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name">{{.FormattedDisplayTitle}}</h2>{{end}}{{/* TODO: check format: if .Collection.Format.ShowDates*/}}<time class="dt-published" datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time><div class="e-content">{{.HTMLContent}}</div></article>
|
<article id="post-body" class="{{.Font}} h-entry">{{if .IsScheduled}}<p class="badge">Scheduled</p>{{end}}{{if .Title.String}}<h2 id="title" class="p-name{{if $.Collection.Format.ShowDates}} dated{{end}}">{{.FormattedDisplayTitle}}</h2>{{end}}{{if and $.Collection.Format.ShowDates (not .IsPinned)}}<time class="dt-published" datetime="{{.Created8601}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</time>{{end}}<div class="e-content">{{.HTMLContent}}</div></article>
|
||||||
|
|
||||||
{{ if .Collection.ShowFooterBranding }}
|
{{ if .Collection.ShowFooterBranding }}
|
||||||
<footer dir="ltr">
|
<footer dir="ltr">
|
||||||
|
@ -93,6 +83,7 @@ article time.dt-published {
|
||||||
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
||||||
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
|
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<script src="/js/localdate.js"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|
||||||
var pinning = false;
|
var pinning = false;
|
||||||
|
|
|
@ -61,8 +61,8 @@ body#collection header nav.tabs a:first-child {
|
||||||
<body id="collection" itemscope itemtype="http://schema.org/WebPage">
|
<body id="collection" itemscope itemtype="http://schema.org/WebPage">
|
||||||
{{template "user-navigation" .}}
|
{{template "user-navigation" .}}
|
||||||
|
|
||||||
{{if .Suspended}}
|
{{if .Silenced}}
|
||||||
{{template "user-suspended"}}
|
{{template "user-silenced"}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<header>
|
<header>
|
||||||
<h1 dir="{{.Direction}}" id="blog-title"><a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
|
<h1 dir="{{.Direction}}" id="blog-title"><a href="/{{if .IsTopLevel}}{{else}}{{.Prefix}}{{.Alias}}/{{end}}" class="h-card p-author u-url" rel="me author">{{.DisplayTitle}}</a></h1>
|
||||||
|
@ -89,8 +89,8 @@ body#collection header nav.tabs a:first-child {
|
||||||
{{template "posts" .}}
|
{{template "posts" .}}
|
||||||
|
|
||||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||||
{{if or (and .Format.Ascending (lt .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||||
|
@ -115,6 +115,7 @@ body#collection header nav.tabs a:first-child {
|
||||||
{{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
|
{{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<script src="/js/h.js"></script>
|
<script src="/js/h.js"></script>
|
||||||
|
<script src="/js/localdate.js"></script>
|
||||||
<script src="/js/postactions.js"></script>
|
<script src="/js/postactions.js"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
var deleting = false;
|
var deleting = false;
|
||||||
|
|