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
|
||||
|
||||
go:
|
||||
- "1.11.x"
|
||||
- "1.13.x"
|
||||
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
|
|
101
CONTRIBUTING.md
|
@ -1,26 +1,99 @@
|
|||
# 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**
|
||||
* 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
|
||||
## Working on WriteFreely
|
||||
|
||||
## 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
|
||||
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 npm install -g less less-plugin-clean-css
|
||||
|
@ -22,7 +22,7 @@ RUN mkdir /stage && \
|
|||
/stage
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.8
|
||||
FROM alpine:3.11
|
||||
|
||||
RUN apk add --no-cache openssl ca-certificates
|
||||
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
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u github.com/karalabe/xgo; \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
fi
|
||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
|
||||
build-windows: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u github.com/karalabe/xgo; \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
fi
|
||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
|
||||
build-darwin: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u github.com/karalabe/xgo; \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
fi
|
||||
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
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u github.com/karalabe/xgo; \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
fi
|
||||
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 :
|
||||
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
||||
|
||||
|
@ -74,15 +86,24 @@ release : clean ui assets
|
|||
cp -r templates $(BUILDPATH)
|
||||
cp -r pages $(BUILDPATH)
|
||||
cp -r static $(BUILDPATH)
|
||||
scripts/invalidate-css.sh $(BUILDPATH)
|
||||
mkdir $(BUILDPATH)/keys
|
||||
$(MAKE) build-linux
|
||||
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-arm6
|
||||
mv build/$(BINARY_NAME)-linux-arm-6 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm6.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-arm7
|
||||
mv build/$(BINARY_NAME)-linux-arm-7 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm7.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-arm64
|
||||
mv build/$(BINARY_NAME)-linux-arm64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-darwin
|
||||
mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(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
|
||||
|
||||
$(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
|
||||
$(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/">
|
||||
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
|
||||
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
|
||||
</a>
|
||||
<a href="https://travis-ci.org/writeas/writefreely">
|
||||
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
|
||||
</a>
|
||||
<a href="https://github.com/writeas/writefreely/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
|
||||
</a>
|
||||
<a href="https://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/">
|
||||
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
|
||||
</a>
|
||||
</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)
|
||||
|
||||
## Features
|
||||
|
||||
* Start a blog for yourself, or host a community of writers
|
||||
* Form larger federated networks, and interact over modern protocols like ActivityPub
|
||||
* Write on a fast, dead-simple, and distraction-free editor
|
||||
* [Format text](https://howto.write.as/getting-started) with Markdown
|
||||
* [Organize posts](https://howto.write.as/organization) with hashtags
|
||||
* Create [static pages](https://howto.write.as/creating-a-static-page)
|
||||
* Publish drafts and let others proofread them by sharing a private link
|
||||
* Create multiple lightweight blogs under a single account
|
||||
* Export all data in plain text files
|
||||
* Read a stream of other posts in your writing community
|
||||
* Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/)
|
||||
* Designed around user privacy and consent
|
||||
### Made for writing
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
WriteFreely is available in these package repositories:
|
||||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||
|
||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||
|
||||
## 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
|
||||
|
||||
Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer/setup).
|
||||
|
||||
## Docker
|
||||
|
||||
Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker).
|
||||
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).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
@ -91,4 +86,4 @@ Before contributing anything, please read our [Contributing Guide](https://githu
|
|||
|
||||
## 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).
|
||||
|
|
167
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.
|
||||
*
|
||||
|
@ -27,6 +27,7 @@ import (
|
|||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/data"
|
||||
"github.com/writeas/web-core/log"
|
||||
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
|
@ -48,6 +49,7 @@ type (
|
|||
Separator template.HTML
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
CollAlias string
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -70,7 +72,7 @@ func canUserInvite(cfg *config.Config, isAdmin bool) bool {
|
|||
}
|
||||
|
||||
func (up *UserPage) SetMessaging(u *User) {
|
||||
//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
||||
// up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
|
||||
}
|
||||
|
||||
const (
|
||||
|
@ -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) {
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqJSON := IsJSON(r)
|
||||
|
||||
// Get params
|
||||
|
@ -156,17 +163,9 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
Username: signup.Alias,
|
||||
HashedPass: hashedPass,
|
||||
HasPass: createdWithPass,
|
||||
Email: zero.NewString("", signup.Email != ""),
|
||||
Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
|
||||
Created: time.Now().Truncate(time.Second).UTC(),
|
||||
}
|
||||
if signup.Email != "" {
|
||||
encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email)
|
||||
if err != nil {
|
||||
log.Error("Unable to encrypt email: %s\n", err)
|
||||
} else {
|
||||
u.Email.String = string(encEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// Create actual user
|
||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
|
||||
|
@ -175,11 +174,7 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
|
||||
// Log invite if needed
|
||||
if signup.InviteCode != "" {
|
||||
cu, err := app.db.GetUserForAuth(signup.Alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -310,16 +305,18 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := &struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
LoginUsername string
|
||||
}{
|
||||
pageForReq(app, r),
|
||||
r.FormValue("to"),
|
||||
template.HTML(""),
|
||||
[]template.HTML{},
|
||||
getTempInfo(app, "login-user", r, w),
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
To: r.FormValue("to"),
|
||||
Message: template.HTML(""),
|
||||
Flashes: []template.HTML{},
|
||||
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||
}
|
||||
|
||||
if earlyError != "" {
|
||||
|
@ -394,6 +391,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
var err error
|
||||
var signin userCredentials
|
||||
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return err
|
||||
}
|
||||
|
||||
// Log in with one-time token if one is given
|
||||
if oneTimeToken != "" {
|
||||
log.Info("Login: Logging user in via token.")
|
||||
|
@ -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."}
|
||||
}
|
||||
}
|
||||
if len(u.HashedPass) == 0 {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
|
||||
}
|
||||
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
log.Error("view articles: %v", err)
|
||||
}
|
||||
|
@ -758,12 +763,12 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
*UserPage
|
||||
AnonymousPosts *[]PublicPost
|
||||
Collections *[]Collection
|
||||
Suspended bool
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, u.Username+"'s Posts", f),
|
||||
AnonymousPosts: p,
|
||||
Collections: c,
|
||||
Suspended: suspended,
|
||||
Silenced: silenced,
|
||||
}
|
||||
d.UserPage.SetMessaging(u)
|
||||
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)
|
||||
// TODO: handle any errors
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
log.Error("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
|
||||
|
||||
NewBlogsDisabled bool
|
||||
Suspended bool
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, u.Username+"'s Blogs", f),
|
||||
Collections: c,
|
||||
UsedCollections: int(uc),
|
||||
NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
|
||||
Suspended: suspended,
|
||||
Silenced: silenced,
|
||||
}
|
||||
d.UserPage.SetMessaging(u)
|
||||
showUserPage(w, "collections", d)
|
||||
|
@ -821,7 +826,7 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
return ErrCollectionNotFound
|
||||
}
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
log.Error("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 {
|
||||
*UserPage
|
||||
*Collection
|
||||
Suspended bool
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||
Collection: c,
|
||||
Suspended: suspended,
|
||||
Silenced: silenced,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
|
||||
showUserPage(w, "collection", obj)
|
||||
return nil
|
||||
|
@ -996,7 +1002,7 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
titleStats = c.DisplayTitle() + " "
|
||||
}
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(u.ID)
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
log.Error("view stats: %v", err)
|
||||
return err
|
||||
|
@ -1007,14 +1013,15 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
Collection *Collection
|
||||
TopPosts *[]PublicPost
|
||||
APFollowers int
|
||||
Suspended bool
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||
VisitsBlog: alias,
|
||||
Collection: c,
|
||||
TopPosts: topPosts,
|
||||
Suspended: suspended,
|
||||
Silenced: silenced,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if app.cfg.App.Federation {
|
||||
folls, err := app.db.GetAPFollowers(c)
|
||||
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)
|
||||
|
||||
enableOauthSlack := app.Config().SlackOauth.ClientID != ""
|
||||
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
|
||||
enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
|
||||
enableOauthGeneric := app.Config().GenericOauth.ClientID != ""
|
||||
enableOauthGitea := app.Config().GiteaOauth.ClientID != ""
|
||||
|
||||
oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get oauth accounts for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
for idx, oauthAccount := range oauthAccounts {
|
||||
switch oauthAccount.Provider {
|
||||
case "slack":
|
||||
enableOauthSlack = false
|
||||
case "write.as":
|
||||
enableOauthWriteAs = false
|
||||
case "gitlab":
|
||||
enableOauthGitLab = false
|
||||
case "generic":
|
||||
oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName
|
||||
oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect
|
||||
enableOauthGeneric = false
|
||||
case "gitea":
|
||||
enableOauthGitea = false
|
||||
}
|
||||
}
|
||||
|
||||
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0
|
||||
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Suspended bool
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut 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),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Suspended: fullUser.IsSilenced(),
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
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)
|
||||
|
@ -1097,3 +1154,29 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
|
|||
// Return value
|
||||
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.
|
||||
*
|
||||
|
@ -37,6 +37,8 @@ import (
|
|||
const (
|
||||
// TODO: delete. don't use this!
|
||||
apCustomHandleDefault = "blog"
|
||||
|
||||
apCacheTime = time.Minute
|
||||
)
|
||||
|
||||
type RemoteUser struct {
|
||||
|
@ -44,6 +46,7 @@ type RemoteUser struct {
|
|||
ActorID string
|
||||
Inbox string
|
||||
SharedInbox string
|
||||
Handle string
|
||||
}
|
||||
|
||||
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 {
|
||||
w.Header().Set("Server", serverSoftware)
|
||||
|
||||
|
@ -80,18 +89,19 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection activities: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
p := c.PersonObject()
|
||||
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, p, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -113,12 +123,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection outbox: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
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)
|
||||
for _, pp := range *posts {
|
||||
pp.Collection = res
|
||||
o := pp.ActivityObject(app.cfg)
|
||||
o := pp.ActivityObject(app)
|
||||
a := activitystreams.NewCreateActivity(o)
|
||||
a.Context = nil
|
||||
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
||||
}
|
||||
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -174,12 +186,12 @@ func handleFetchCollectionFollowers(app *App, w http.ResponseWriter, r *http.Req
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection followers: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
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)
|
||||
}
|
||||
*/
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -228,12 +241,12 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection following: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
@ -251,6 +264,7 @@ func handleFetchCollectionFollowing(app *App, w http.ResponseWriter, r *http.Req
|
|||
// Return outbox page
|
||||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "following", 0, p)
|
||||
ocp.OrderedItems = []interface{}{}
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ocp, http.StatusOK)
|
||||
}
|
||||
|
||||
|
@ -270,12 +284,12 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
// TODO: return Reject?
|
||||
return err
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection inbox: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
@ -382,6 +396,13 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
go func() {
|
||||
if to == nil {
|
||||
if debugging {
|
||||
log.Error("No `to` value!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
am, err := a.Serialize()
|
||||
if err != nil {
|
||||
|
@ -390,10 +411,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
am["@context"] = []string{activitystreams.Namespace}
|
||||
|
||||
if to == nil {
|
||||
log.Error("No to! %v", err)
|
||||
return
|
||||
}
|
||||
err = makeActivityPost(app.cfg.App.Host, p, fullActor.Inbox, am)
|
||||
if err != nil {
|
||||
log.Error("Unable to make activity POST: %v", err)
|
||||
|
@ -477,7 +494,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
|
||||
r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
|
||||
r.Header.Add("Content-Type", "application/activity+json")
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
@ -527,7 +544,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
|
||||
r, _ := http.NewRequest("GET", url, nil)
|
||||
r.Header.Add("Accept", "application/activity+json")
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
|
||||
if debugging {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -564,7 +581,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
}
|
||||
p.Collection.hostName = app.cfg.App.Host
|
||||
actor := p.Collection.PersonObject(collID)
|
||||
na := p.ActivityObject(app.cfg)
|
||||
na := p.ActivityObject(app)
|
||||
|
||||
// Add followers
|
||||
p.Collection.ID = collID
|
||||
|
@ -593,7 +610,12 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
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 {
|
||||
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)
|
||||
na := p.ActivityObject(app.cfg)
|
||||
na := p.ActivityObject(app)
|
||||
|
||||
// Add followers
|
||||
p.Collection.ID = collID
|
||||
|
@ -628,18 +650,25 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
inbox = f.Inbox
|
||||
}
|
||||
if _, ok := inboxes[inbox]; ok {
|
||||
// check if we're already sending to this shared inbox
|
||||
inboxes[inbox] = append(inboxes[inbox], f.ActorID)
|
||||
} else {
|
||||
// add the new shared inbox to the list
|
||||
inboxes[inbox] = []string{f.ActorID}
|
||||
}
|
||||
}
|
||||
|
||||
var activity *activitystreams.Activity
|
||||
// for each one of the shared inboxes
|
||||
for si, instFolls := range inboxes {
|
||||
// add all followers from that instance
|
||||
// to the CC field
|
||||
na.CC = []string{}
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
var activity *activitystreams.Activity
|
||||
// create a new "Create" activity
|
||||
// with our article as object
|
||||
if isUpdate {
|
||||
activity = activitystreams.NewUpdateActivity(na)
|
||||
} else {
|
||||
|
@ -647,17 +676,47 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
activity.To = na.To
|
||||
activity.CC = na.CC
|
||||
}
|
||||
// and post it to that sharedInbox
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, si, activity)
|
||||
if err != nil {
|
||||
log.Error("Couldn't post! %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// re-create the object so that the CC list gets reset and has
|
||||
// the mentioned users. This might seem wasteful but the code is
|
||||
// cleaner than adding the mentioned users to CC here instead of
|
||||
// in p.ActivityObject()
|
||||
na = p.ActivityObject(app)
|
||||
for _, tag := range na.Tag {
|
||||
if tag.Type == "Mention" {
|
||||
activity = activitystreams.NewCreateActivity(na)
|
||||
activity.To = na.To
|
||||
activity.CC = na.CC
|
||||
// This here might be redundant in some cases as we might have already
|
||||
// sent this to the sharedInbox of this instance above, but we need too
|
||||
// much logic to catch this at the expense of the odd extra request.
|
||||
// I don't believe we'd ever have too many mentions in a single post that this
|
||||
// could become a burden.
|
||||
remoteUser, err := getRemoteUser(app, tag.HRef)
|
||||
if err != nil {
|
||||
log.Error("Unable to find remote user %s. Skipping: %v", tag.HRef, err)
|
||||
continue
|
||||
}
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity)
|
||||
if err != nil {
|
||||
log.Error("Couldn't post! %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||
u := RemoteUser{ActorID: actorID}
|
||||
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 {
|
||||
case err == sql.ErrNoRows:
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -743,3 +819,7 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||
}
|
||||
|
|
145
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.
|
||||
*
|
||||
|
@ -90,6 +90,18 @@ type instanceContent struct {
|
|||
Updated time.Time
|
||||
}
|
||||
|
||||
type AdminPage struct {
|
||||
UpdateAvailable bool
|
||||
}
|
||||
|
||||
func NewAdminPage(app *App) *AdminPage {
|
||||
ap := &AdminPage{}
|
||||
if app.updates != nil {
|
||||
ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
|
||||
}
|
||||
return ap
|
||||
}
|
||||
|
||||
func (c instanceContent) UpdatedFriendly() string {
|
||||
/*
|
||||
// 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 {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Message string
|
||||
|
||||
UsersCount, CollectionsCount, PostsCount int64
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
// Get user stats
|
||||
p.UsersCount = app.db.GetAllUsersCount()
|
||||
var err error
|
||||
p.CollectionsCount, err = app.db.GetTotalCollections()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PostsCount, err = app.db.GetTotalPosts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
showUserPage(w, "admin", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
updateAppStats()
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
SysStatus systemStatus
|
||||
Config config.AppCfg
|
||||
|
||||
Message, ConfigMessage string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
SysStatus: sysStatus,
|
||||
Config: app.cfg.App,
|
||||
|
||||
|
@ -116,13 +159,34 @@ func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
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
|
||||
}
|
||||
|
||||
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
|
@ -131,9 +195,10 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
TotalUsers int64
|
||||
TotalPages []int
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Users", nil),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
UserPage: NewUserPage(app, r, u, "Users", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
p.TotalUsers = app.db.GetAllUsersCount()
|
||||
|
@ -169,6 +234,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
|
@ -179,15 +245,20 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
TotalPosts int64
|
||||
ClearEmail string
|
||||
}{
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
Colls: []inspectedCollection{},
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
Colls: []inspectedCollection{},
|
||||
}
|
||||
|
||||
var err error
|
||||
p.User, err = app.db.GetUserForAuth(username)
|
||||
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)
|
||||
|
@ -259,8 +330,8 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht
|
|||
err = app.db.SetUserStatus(user.ID, UserSilenced)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("toggle user suspended: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v")}
|
||||
log.Error("toggle user silenced: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
|
||||
}
|
||||
|
@ -300,14 +371,16 @@ func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.
|
|||
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Pages []*instanceContent
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
var err error
|
||||
|
@ -364,14 +437,16 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Banner *instanceContent
|
||||
Content *instanceContent
|
||||
}{
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
var err error
|
||||
|
@ -471,7 +546,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
if err != nil {
|
||||
m = "?cm=" + err.Error()
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"}
|
||||
return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
|
||||
}
|
||||
|
||||
func updateAppStats() {
|
||||
|
@ -524,3 +599,39 @@ func adminResetPassword(app *App, u *User, newPass string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
check := r.URL.Query().Get("check")
|
||||
|
||||
if check == "now" && app.cfg.App.UpdateChecks {
|
||||
app.updates.CheckNow()
|
||||
}
|
||||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
CurReleaseNotesURL string
|
||||
LastChecked string
|
||||
LastChecked8601 string
|
||||
LatestVersion string
|
||||
LatestReleaseURL string
|
||||
LatestReleaseNotesURL string
|
||||
CheckFailed bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Updates", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
}
|
||||
p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
|
||||
if app.cfg.App.UpdateChecks {
|
||||
p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
|
||||
p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
|
||||
p.LatestVersion = app.updates.LatestVersion()
|
||||
p.LatestReleaseURL = app.updates.ReleaseURL()
|
||||
p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
|
||||
p.UpdateAvailable = app.updates.AreAvailable()
|
||||
p.CheckFailed = app.updates.checkError != nil
|
||||
}
|
||||
|
||||
showUserPage(w, "app-updates", p)
|
||||
return nil
|
||||
}
|
||||
|
|
86
app.go
|
@ -30,7 +30,7 @@ import (
|
|||
"github.com/gorilla/schema"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/writeas/go-strip-markdown"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/converter"
|
||||
|
@ -56,7 +56,7 @@ var (
|
|||
debugging bool
|
||||
|
||||
// Software version can be set from git env using -ldflags
|
||||
softwareVer = "0.11.1"
|
||||
softwareVer = "0.12.0"
|
||||
|
||||
// DEPRECATED VARS
|
||||
isSingleUser bool
|
||||
|
@ -70,8 +70,9 @@ type App struct {
|
|||
cfg *config.Config
|
||||
cfgFile string
|
||||
keys *key.Keychain
|
||||
sessionStore *sessions.CookieStore
|
||||
sessionStore sessions.Store
|
||||
formDecoder *schema.Decoder
|
||||
updates *updatesCache
|
||||
|
||||
timeline *localTimeline
|
||||
}
|
||||
|
@ -101,6 +102,14 @@ func (app *App) SetKeys(k *key.Keychain) {
|
|||
app.keys = k
|
||||
}
|
||||
|
||||
func (app *App) SessionStore() sessions.Store {
|
||||
return app.sessionStore
|
||||
}
|
||||
|
||||
func (app *App) SetSessionStore(s sessions.Store) {
|
||||
app.sessionStore = s
|
||||
}
|
||||
|
||||
// Apper is the interface for getting data into and out of a WriteFreely
|
||||
// instance (or "App").
|
||||
//
|
||||
|
@ -212,6 +221,10 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
return handleViewPad(app, w, r)
|
||||
}
|
||||
|
||||
if app.cfg.App.Private {
|
||||
return viewLogin(app, w, r)
|
||||
}
|
||||
|
||||
if land := app.cfg.App.LandingPath(); land != "/" {
|
||||
return impart.HTTPError{http.StatusFound, land}
|
||||
}
|
||||
|
@ -225,6 +238,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Flashes []template.HTML
|
||||
Banner template.HTML
|
||||
Content template.HTML
|
||||
|
@ -232,6 +246,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
ForcedLanding bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
ForcedLanding: forceLanding,
|
||||
}
|
||||
|
||||
|
@ -363,6 +378,8 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("init keys: %s", err)
|
||||
}
|
||||
apper.App().InitUpdates()
|
||||
|
||||
apper.App().InitSession()
|
||||
|
||||
apper.App().InitDecoder()
|
||||
|
@ -398,6 +415,11 @@ func Serve(app *App, r *mux.Router) {
|
|||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Start gopher server
|
||||
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
|
||||
go initGopher(app)
|
||||
}
|
||||
|
||||
// Start web application server
|
||||
var bindAddress = app.cfg.Server.Bind
|
||||
if bindAddress == "" {
|
||||
|
@ -681,13 +703,59 @@ func ResetPassword(apper Apper, username string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// DoDeleteAccount runs the confirmation and account delete process.
|
||||
func DoDeleteAccount(apper Apper, username string) error {
|
||||
// Connect to the database
|
||||
apper.LoadConfig()
|
||||
connectToDatabase(apper.App())
|
||||
defer shutdown(apper.App())
|
||||
|
||||
// check user exists
|
||||
u, err := apper.App().db.GetUserForAuth(username)
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
userID := u.ID
|
||||
|
||||
// do not delete the admin account
|
||||
// TODO: check for other admins and skip?
|
||||
if u.IsAdmin() {
|
||||
log.Error("Can not delete admin account")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// confirm deletion, w/ w/out posts
|
||||
prompt := promptui.Prompt{
|
||||
Templates: &promptui.PromptTemplates{
|
||||
Success: "{{ . | bold | faint }}: ",
|
||||
},
|
||||
Label: fmt.Sprintf("Really delete user : %s", username),
|
||||
IsConfirm: true,
|
||||
}
|
||||
_, err = prompt.Run()
|
||||
if err != nil {
|
||||
log.Info("Aborted...")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
log.Info("Deleting...")
|
||||
err = apper.App().db.DeleteAccount(userID)
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("Success.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectToDatabase(app *App) {
|
||||
log.Info("Connecting to %s database...", app.cfg.Database.Type)
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
if app.cfg.Database.Type == driverMySQL {
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", 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)
|
||||
} else if app.cfg.Database.Type == driverSQLite {
|
||||
if !SQLiteEnabled {
|
||||
|
@ -824,3 +892,13 @@ func adminInitDatabase(app *App) error {
|
|||
log.Info("Done.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServerUserAgent returns a User-Agent string to use in external requests. The
|
||||
// hostName parameter may be left empty.
|
||||
func ServerUserAgent(hostName string) string {
|
||||
hostUAStr := ""
|
||||
if hostName != "" {
|
||||
hostUAStr = "; +" + hostName
|
||||
}
|
||||
return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")"
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -65,6 +65,7 @@ var reservedUsernames = map[string]bool{
|
|||
"metadata": true,
|
||||
"new": true,
|
||||
"news": true,
|
||||
"oauth": true,
|
||||
"post": true,
|
||||
"posts": 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.
|
||||
*
|
||||
|
@ -11,113 +11,157 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// General options usable with other commands
|
||||
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
|
||||
configFile := flag.String("c", "config.ini", "The configuration file to use")
|
||||
cli.VersionPrinter = func(c *cli.Context) {
|
||||
fmt.Printf("%s\n", c.App.Version)
|
||||
}
|
||||
app := &cli.App{
|
||||
Name: "WriteFreely",
|
||||
Usage: "A beautifully pared-down blogging platform",
|
||||
Version: writefreely.FormatVersion(),
|
||||
Action: legacyActions, // legacy due to use of flags for switching actions
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "create-config",
|
||||
Value: false,
|
||||
Usage: "Generate a basic configuration",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "config",
|
||||
Value: false,
|
||||
Usage: "Interactive configuration process",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sections",
|
||||
Value: "server db app",
|
||||
Usage: "Which sections of the configuration to go through (requires --config)\n" +
|
||||
"valid values are any combination of 'server', 'db' and 'app' \n" +
|
||||
"example: writefreely --config --sections \"db app\"",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "gen-keys",
|
||||
Value: false,
|
||||
Usage: "Generate encryption and authentication keys",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "init-db",
|
||||
Value: false,
|
||||
Usage: "Initialize app database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migrate",
|
||||
Value: false,
|
||||
Usage: "Migrate the database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-admin",
|
||||
Usage: "Create an admin with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-user",
|
||||
Usage: "Create a regular user with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "delete-user",
|
||||
Usage: "Delete a user with the given username",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "reset-pass",
|
||||
Usage: "Reset the given user's password",
|
||||
Hidden: true,
|
||||
},
|
||||
}, // legacy flags (set to hidden to eventually switch to bash-complete compatible format)
|
||||
}
|
||||
|
||||
// Setup actions
|
||||
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
|
||||
doConfig := flag.Bool("config", false, "Run the configuration process")
|
||||
configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+
|
||||
"valid values are any combination of 'server', 'db' and 'app' "+
|
||||
"example: writefreely --config --sections \"db app\"")
|
||||
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
|
||||
createSchema := flag.Bool("init-db", false, "Initialize app database")
|
||||
migrate := flag.Bool("migrate", false, "Migrate the database")
|
||||
defaultFlags := []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "c",
|
||||
Value: "config.ini",
|
||||
Usage: "Load configuration from `FILE`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Value: false,
|
||||
Usage: "Enables debug logging",
|
||||
},
|
||||
}
|
||||
|
||||
// Admin actions
|
||||
createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password")
|
||||
createUser := flag.String("create-user", "", "Create a regular user with the given username:password")
|
||||
resetPassUser := flag.String("reset-pass", "", "Reset the given user's password")
|
||||
outputVersion := flag.Bool("v", false, "Output the current version")
|
||||
flag.Parse()
|
||||
app.Flags = append(app.Flags, defaultFlags...)
|
||||
|
||||
app := writefreely.NewApp(*configFile)
|
||||
app.Commands = []*cli.Command{
|
||||
&cmdUser,
|
||||
&cmdDB,
|
||||
&cmdConfig,
|
||||
&cmdKeys,
|
||||
&cmdServe,
|
||||
}
|
||||
|
||||
if *outputVersion {
|
||||
writefreely.OutputVersion()
|
||||
os.Exit(0)
|
||||
} else if *createConfig {
|
||||
err := writefreely.CreateConfig(app)
|
||||
err := app.Run(os.Args)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyActions(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
|
||||
switch true {
|
||||
case c.IsSet("create-config"):
|
||||
return writefreely.CreateConfig(app)
|
||||
case c.IsSet("config"):
|
||||
writefreely.DoConfig(app, c.String("sections"))
|
||||
return nil
|
||||
case c.IsSet("gen-keys"):
|
||||
return writefreely.GenerateKeyFiles(app)
|
||||
case c.IsSet("init-db"):
|
||||
return writefreely.CreateSchema(app)
|
||||
case c.IsSet("migrate"):
|
||||
return writefreely.Migrate(app)
|
||||
case c.IsSet("create-admin"):
|
||||
username, password, err := parseCredentials(c.String("create-admin"))
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *doConfig {
|
||||
writefreely.DoConfig(app, *configSections)
|
||||
os.Exit(0)
|
||||
} else if *genKeys {
|
||||
err := writefreely.GenerateKeyFiles(app)
|
||||
return writefreely.CreateUser(app, username, password, true)
|
||||
case c.IsSet("create-user"):
|
||||
username, password, err := parseCredentials(c.String("create-user"))
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createSchema {
|
||||
err := writefreely.CreateSchema(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createAdmin != "" {
|
||||
username, password, err := userPass(*createAdmin, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createUser != "" {
|
||||
username, password, err := userPass(*createUser, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *resetPassUser != "" {
|
||||
err := writefreely.ResetPassword(app, *resetPassUser)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *migrate {
|
||||
err := writefreely.Migrate(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
return writefreely.CreateUser(app, username, password, false)
|
||||
case c.IsSet("delete-user"):
|
||||
return writefreely.DoDeleteAccount(app, c.String("delete-user"))
|
||||
case c.IsSet("reset-pass"):
|
||||
return writefreely.ResetPassword(app, c.String("reset-pass"))
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
var err error
|
||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||
app, err = writefreely.Initialize(app, *debugPtr)
|
||||
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set app routes
|
||||
|
@ -127,20 +171,14 @@ func main() {
|
|||
|
||||
// Serve the application
|
||||
writefreely.Serve(app, r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func userPass(credStr string, isAdmin bool) (user string, pass string, err error) {
|
||||
creds := strings.Split(credStr, ":")
|
||||
func parseCredentials(credentialString string) (string, string, error) {
|
||||
creds := strings.Split(credentialString, ":")
|
||||
if len(creds) != 2 {
|
||||
c := "user"
|
||||
if isAdmin {
|
||||
c = "admin"
|
||||
}
|
||||
err = fmt.Errorf("usage: writefreely --create-%s username:password", c)
|
||||
return
|
||||
return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
|
||||
}
|
||||
|
||||
user = creds[0]
|
||||
pass = creds[1]
|
||||
return
|
||||
return creds[0], creds[1], nil
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
@ -47,6 +47,7 @@ type (
|
|||
Language string `schema:"lang" json:"lang,omitempty"`
|
||||
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
|
||||
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
|
||||
Signature string `datastore:"post_signature" schema:"signature" json:"-"`
|
||||
Public bool `datastore:"public" json:"public"`
|
||||
Visibility collVisibility `datastore:"private" json:"-"`
|
||||
Format string `datastore:"format" json:"format,omitempty"`
|
||||
|
@ -63,6 +64,7 @@ type (
|
|||
TotalPosts int `json:"total_posts"`
|
||||
Owner *User `json:"owner,omitempty"`
|
||||
Posts *[]PublicPost `json:"posts,omitempty"`
|
||||
Format *CollectionFormat
|
||||
}
|
||||
DisplayCollection struct {
|
||||
*CollectionObj
|
||||
|
@ -70,8 +72,7 @@ type (
|
|||
IsTopLevel bool
|
||||
CurrentPage int
|
||||
TotalPages int
|
||||
Format *CollectionFormat
|
||||
Suspended bool
|
||||
Silenced bool
|
||||
}
|
||||
SubmittedCollection struct {
|
||||
// Data used for updating a given collection
|
||||
|
@ -91,6 +92,7 @@ type (
|
|||
Description *string `schema:"description" json:"description"`
|
||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
|
||||
Script *sql.NullString `schema:"script" json:"script"`
|
||||
Signature *sql.NullString `schema:"signature" json:"signature"`
|
||||
Visibility *int `schema:"visibility" json:"public"`
|
||||
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
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(userID)
|
||||
silenced, err := app.db.IsUserSilenced(userID)
|
||||
if err != nil {
|
||||
log.Error("new collection: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// TODO: check suspended
|
||||
// TODO: check status for silenced
|
||||
app.db.GetPostsCount(res, isCollOwner)
|
||||
// Strip non-public information
|
||||
res.Collection.ForPublic()
|
||||
|
@ -556,6 +558,13 @@ type CollectionPage struct {
|
|||
CanInvite bool
|
||||
}
|
||||
|
||||
func NewCollectionObj(c *Collection) *CollectionObj {
|
||||
return &CollectionObj{
|
||||
Collection: *c,
|
||||
Format: c.NewFormat(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CollectionObj) ScriptDisplay() template.JS {
|
||||
return template.JS(c.Script)
|
||||
}
|
||||
|
@ -648,6 +657,16 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
|
|||
uname = u.Username
|
||||
}
|
||||
|
||||
// TODO: move this to all permission checks?
|
||||
suspended, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("process protected collection permissions: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
if suspended {
|
||||
return nil, ErrCollectionNotFound
|
||||
}
|
||||
|
||||
// See if we've authorized this collection
|
||||
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 {
|
||||
coll := &DisplayCollection{
|
||||
CollectionObj: &CollectionObj{Collection: *c},
|
||||
CollectionObj: NewCollectionObj(c),
|
||||
CurrentPage: page,
|
||||
Prefix: cr.prefix,
|
||||
IsTopLevel: isSingleUser,
|
||||
Format: c.NewFormat(),
|
||||
}
|
||||
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||
return coll
|
||||
|
@ -738,7 +756,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("view collection: %v", err)
|
||||
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") {
|
||||
ac := c.PersonObject()
|
||||
ac.Context = []interface{}{activitystreams.Namespace}
|
||||
setCacheControl(w, apCacheTime)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !isOwner && suspended {
|
||||
if !isOwner && silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
displayPage.Suspended = isOwner && suspended
|
||||
displayPage.Silenced = isOwner && silenced
|
||||
displayPage.Owner = owner
|
||||
coll.Owner = displayPage.Owner
|
||||
|
||||
|
@ -840,6 +859,19 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
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 {
|
||||
vars := mux.Vars(r)
|
||||
tag := vars["tag"]
|
||||
|
@ -909,7 +941,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
return ErrCollectionNotFound
|
||||
}
|
||||
}
|
||||
displayPage.Suspended = owner != nil && owner.IsSilenced()
|
||||
displayPage.Silenced = owner != nil && owner.IsSilenced()
|
||||
displayPage.Owner = owner
|
||||
coll.Owner = displayPage.Owner
|
||||
// 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 {
|
||||
log.Error("existing collection: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
if r.Method == "DELETE" {
|
||||
|
|
|
@ -9,6 +9,7 @@ password = changeme
|
|||
database = writefreely
|
||||
host = db
|
||||
port = 3306
|
||||
tls = false
|
||||
|
||||
[app]
|
||||
site_name = WriteFreely Example Blog!
|
||||
|
@ -23,4 +24,5 @@ max_blogs = 1
|
|||
federation = true
|
||||
public_stats = true
|
||||
private = false
|
||||
update_checks = true
|
||||
|
||||
|
|
|
@ -12,8 +12,9 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"gopkg.in/ini.v1"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -42,6 +43,10 @@ type (
|
|||
PagesParentDir string `ini:"pages_parent_dir"`
|
||||
KeysParentDir string `ini:"keys_parent_dir"`
|
||||
|
||||
HashSeed string `ini:"hash_seed"`
|
||||
|
||||
GopherPort int `ini:"gopher_port"`
|
||||
|
||||
Dev bool `ini:"-"`
|
||||
}
|
||||
|
||||
|
@ -54,6 +59,56 @@ type (
|
|||
Database string `ini:"database"`
|
||||
Host string `ini:"host"`
|
||||
Port int `ini:"port"`
|
||||
TLS bool `ini:"tls"`
|
||||
}
|
||||
|
||||
WriteAsOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
AuthLocation string `ini:"auth_location"`
|
||||
TokenLocation string `ini:"token_location"`
|
||||
InspectLocation string `ini:"inspect_location"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GitlabOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GiteaOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
SlackOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
TeamID string `ini:"team_id"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GenericOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
TokenEndpoint string `ini:"token_endpoint"`
|
||||
InspectEndpoint string `ini:"inspect_endpoint"`
|
||||
AuthEndpoint string `ini:"auth_endpoint"`
|
||||
AllowDisconnect bool `ini:"allow_disconnect"`
|
||||
}
|
||||
|
||||
// AppCfg holds values that affect how the application functions
|
||||
|
@ -73,6 +128,7 @@ type (
|
|||
|
||||
// Site functionality
|
||||
Chorus bool `ini:"chorus"`
|
||||
Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
|
||||
DisableDrafts bool `ini:"disable_drafts"`
|
||||
|
||||
// Users
|
||||
|
@ -94,13 +150,24 @@ type (
|
|||
|
||||
// Defaults
|
||||
DefaultVisibility string `ini:"default_visibility"`
|
||||
|
||||
// Check for Updates
|
||||
UpdateChecks bool `ini:"update_checks"`
|
||||
|
||||
// Disable password authentication if use only Oauth
|
||||
DisablePasswordAuth bool `ini:"disable_password_auth"`
|
||||
}
|
||||
|
||||
// Config holds the complete configuration for running a writefreely instance
|
||||
Config struct {
|
||||
Server ServerCfg `ini:"server"`
|
||||
Database DatabaseCfg `ini:"database"`
|
||||
App AppCfg `ini:"app"`
|
||||
Server ServerCfg `ini:"server"`
|
||||
Database DatabaseCfg `ini:"database"`
|
||||
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
|
||||
}
|
||||
|
||||
func (ac AppCfg) SignupPath() string {
|
||||
if !ac.OpenRegistration {
|
||||
return ""
|
||||
}
|
||||
if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") {
|
||||
return "/signup"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// Load reads the given configuration file, then parses and returns it as a Config.
|
||||
func Load(fname string) (*Config, error) {
|
||||
if fname == "" {
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Federation usage stats",
|
||||
Label: "Usage stats (active users, posts)",
|
||||
Items: []string{"Public", "Private"},
|
||||
}
|
||||
_, fedStatsType, err := selPrompt.Run()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// +build wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -18,3 +18,11 @@ package writefreely
|
|||
func (db *datastore) isDuplicateKeyErr(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// +build !sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -28,3 +28,25 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrCollationMix
|
||||
}
|
||||
} else {
|
||||
log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// +build sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -48,3 +48,25 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrCollationMix
|
||||
}
|
||||
} else {
|
||||
log.Error("isIgnorableError: failed check for unrecognized driver '%s'", db.driverName)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
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.
|
||||
*
|
||||
|
@ -11,8 +11,11 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/writeas/web-core/silobridge"
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -20,6 +23,7 @@ import (
|
|||
"github.com/guregu/null"
|
||||
"github.com/guregu/null/zero"
|
||||
uuid "github.com/nu7hatch/gouuid"
|
||||
"github.com/writeas/activityserve"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/activitypub"
|
||||
|
@ -35,6 +39,9 @@ import (
|
|||
|
||||
const (
|
||||
mySQLErrDuplicateKey = 1062
|
||||
mySQLErrCollationMix = 1267
|
||||
mySQLErrTooManyConns = 1040
|
||||
mySQLErrMaxUserConns = 1203
|
||||
|
||||
driverMySQL = "mysql"
|
||||
driverSQLite = "sqlite3"
|
||||
|
@ -61,7 +68,7 @@ type writestore interface {
|
|||
GetAccessToken(userID int64) (string, error)
|
||||
GetTemporaryAccessToken(userID int64, validSecs int) (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
|
||||
ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error
|
||||
|
||||
|
@ -124,6 +131,13 @@ type writestore interface {
|
|||
GetUserLastPostTime(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
|
||||
}
|
||||
|
||||
|
@ -132,6 +146,8 @@ type datastore struct {
|
|||
driverName string
|
||||
}
|
||||
|
||||
var _ writestore = &datastore{}
|
||||
|
||||
func (db *datastore) now() string {
|
||||
if db.driverName == driverSQLite {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if db.PostIDExists(u.Username) {
|
||||
return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
|
||||
|
@ -308,18 +325,18 @@ func (db *datastore) GetUserByID(id int64) (*User, error) {
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// IsUserSuspended returns true if the user account associated with id is
|
||||
// currently suspended.
|
||||
func (db *datastore) IsUserSuspended(id int64) (bool, error) {
|
||||
// IsUserSilenced returns true if the user account associated with id is
|
||||
// currently silenced.
|
||||
func (db *datastore) IsUserSilenced(id int64) (bool, error) {
|
||||
u := &User{ID: id}
|
||||
|
||||
err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
|
||||
switch {
|
||||
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:
|
||||
log.Error("Couldn't SELECT user password: %v", err)
|
||||
return false, fmt.Errorf("is user suspended: %v", err)
|
||||
log.Error("Couldn't SELECT user status: %v", err)
|
||||
return false, fmt.Errorf("is user silenced: %v", err)
|
||||
}
|
||||
|
||||
return u.IsSilenced(), nil
|
||||
|
@ -775,19 +792,22 @@ func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Coll
|
|||
c := &Collection{}
|
||||
|
||||
// FIXME: change Collection to reflect database values. Add helper functions to get actual values
|
||||
var styleSheet, script, 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)
|
||||
var styleSheet, script, signature, format zero.String
|
||||
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 {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||
case db.isHighLoadError(err):
|
||||
return nil, ErrUnavailable
|
||||
case err != nil:
|
||||
log.Error("Failed selecting from collections: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
c.StyleSheet = styleSheet.String
|
||||
c.Script = script.String
|
||||
c.Signature = signature.String
|
||||
c.Format = format.String
|
||||
c.Public = c.IsPublic()
|
||||
|
||||
|
@ -831,7 +851,8 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro
|
|||
SetStringPtr(c.Title, "title").
|
||||
SetStringPtr(c.Description, "description").
|
||||
SetNullString(c.StyleSheet, "style_sheet").
|
||||
SetNullString(c.Script, "script")
|
||||
SetNullString(c.Script, "script").
|
||||
SetNullString(c.Signature, "post_signature")
|
||||
|
||||
if c.Format != nil {
|
||||
cf := &CollectionFormat{Format: c.Format.String}
|
||||
|
@ -1132,6 +1153,7 @@ func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, inclu
|
|||
break
|
||||
}
|
||||
p.extractData()
|
||||
p.augmentContent(c)
|
||||
p.formatContent(cfg, c, includeFuture)
|
||||
|
||||
posts = append(posts, p.processPost())
|
||||
|
@ -1196,6 +1218,7 @@ func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag strin
|
|||
break
|
||||
}
|
||||
p.extractData()
|
||||
p.augmentContent(c)
|
||||
p.formatContent(cfg, c, includeFuture)
|
||||
|
||||
posts = append(posts, p.processPost())
|
||||
|
@ -1572,6 +1595,7 @@ func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[
|
|||
break
|
||||
}
|
||||
p.extractData()
|
||||
p.augmentContent(&coll.Collection)
|
||||
|
||||
pp := p.processPost()
|
||||
pp.Collection = coll
|
||||
|
@ -1622,6 +1646,40 @@ func (db *datastore) GetPublishableCollections(u *User, hostName string) (*[]Col
|
|||
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 {
|
||||
s := userMeStats{}
|
||||
|
||||
|
@ -2005,7 +2063,7 @@ func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error {
|
|||
func (db *datastore) GetCollectionRedirect(alias string) (new string) {
|
||||
row := db.QueryRow("SELECT new_alias FROM collectionredirects WHERE prev_alias = ?", alias)
|
||||
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)
|
||||
}
|
||||
return
|
||||
|
@ -2104,22 +2162,13 @@ func (db *datastore) CollectionHasAttribute(id int64, attr string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func (db *datastore) DeleteAccount(userID int64) (l *string, err error) {
|
||||
debug := ""
|
||||
l = &debug
|
||||
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
stringLogln(l, "Unable to begin: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// DeleteAccount will delete the entire account for userID
|
||||
func (db *datastore) DeleteAccount(userID int64) error {
|
||||
// Get all collections
|
||||
rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to get collections: %v", err)
|
||||
return
|
||||
log.Error("Unable to get collections: %v", err)
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
colls := []Collection{}
|
||||
|
@ -2127,103 +2176,158 @@ func (db *datastore) DeleteAccount(userID int64) (l *string, err error) {
|
|||
for rows.Next() {
|
||||
err = rows.Scan(&c.ID, &c.Alias)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to scan collection cols: %v", err)
|
||||
return
|
||||
log.Error("Unable to scan collection cols: %v", err)
|
||||
return err
|
||||
}
|
||||
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
|
||||
for _, c := range colls {
|
||||
// TODO: user deleteCollection() func
|
||||
// Delete tokens
|
||||
res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err)
|
||||
return
|
||||
log.Error("Unable to delete attributes on %s: %v", c.Alias, err)
|
||||
return err
|
||||
}
|
||||
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
|
||||
res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err)
|
||||
return
|
||||
log.Error("Unable to delete passwords on %s: %v", c.Alias, err)
|
||||
return err
|
||||
}
|
||||
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
|
||||
res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err)
|
||||
return
|
||||
log.Error("Unable to delete redirects on %s: %v", c.Alias, err)
|
||||
return err
|
||||
}
|
||||
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
|
||||
res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete collections: %v", err)
|
||||
return
|
||||
log.Error("Unable to delete collections: %v", err)
|
||||
return err
|
||||
}
|
||||
rs, _ := res.RowsAffected()
|
||||
stringLogln(l, "Deleted %d from collections", rs)
|
||||
log.Info("Deleted %d from collections", rs)
|
||||
|
||||
// Delete tokens
|
||||
res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete access tokens: %v", err)
|
||||
return
|
||||
log.Error("Unable to delete access tokens: %v", err)
|
||||
return err
|
||||
}
|
||||
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
|
||||
// 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)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete posts: %v", err)
|
||||
return
|
||||
log.Error("Unable to delete posts: %v", err)
|
||||
return err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete attributes: %v", err)
|
||||
return
|
||||
log.Error("Unable to delete attributes: %v", err)
|
||||
return err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to delete user: %v", err)
|
||||
return
|
||||
log.Error("Unable to delete user: %v", err)
|
||||
return err
|
||||
}
|
||||
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()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
stringLogln(l, "Unable to commit: %v", err)
|
||||
return
|
||||
log.Error("Unable to commit: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return
|
||||
// TODO: federate delete actor
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) {
|
||||
|
@ -2272,7 +2376,7 @@ func (db *datastore) GetUserInvite(id string) (*Invite, error) {
|
|||
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)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
case err == sql.ErrNoRows, db.isIgnorableError(err):
|
||||
return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."}
|
||||
case err != nil:
|
||||
log.Error("Failed selecting invite: %v", err)
|
||||
|
@ -2453,6 +2557,104 @@ func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
|
|||
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
|
||||
// initialized with the correct schema.
|
||||
// Currently, it checks to see if the `users` table exists.
|
||||
|
@ -2475,6 +2677,11 @@ func (db *datastore) DatabaseInitialized() bool {
|
|||
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{}) {
|
||||
*log += fmt.Sprintf(s+"\n", v...)
|
||||
}
|
||||
|
@ -2483,3 +2690,52 @@ func handleFailedPostInsert(err error) error {
|
|||
log.Error("Couldn't insert into posts: %v", 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()
|
||||
}
|
||||
|
13
errors.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* 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."}
|
||||
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
|
||||
|
||||
ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."}
|
||||
|
||||
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
|
||||
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
|
||||
|
@ -45,10 +47,13 @@ var (
|
|||
ErrPostUnpublished = impart.HTTPError{Status: http.StatusGone, Message: "Post unpublished by author."}
|
||||
ErrPostFetchError = impart.HTTPError{Status: http.StatusInternalServerError, Message: "We encountered an error getting the post. The humans have been alerted."}
|
||||
|
||||
ErrUserNotFound = impart.HTTPError{http.StatusNotFound, "User doesn't exist."}
|
||||
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
|
||||
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."}
|
||||
|
||||
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
|
||||
|
|
4
feed.go
|
@ -36,12 +36,12 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("view feed: get user: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
|
53
go.mod
|
@ -1,60 +1,61 @@
|
|||
module github.com/writeas/writefreely
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/color v1.7.0
|
||||
github.com/go-sql-driver/mysql v1.4.1
|
||||
github.com/fatih/color v1.9.0
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
github.com/go-test/deep v1.0.1 // indirect
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||
github.com/gorilla/feeds v1.1.0
|
||||
github.com/gorilla/mux v1.7.0
|
||||
github.com/gorilla/schema v1.0.2
|
||||
github.com/gorilla/sessions v1.1.3
|
||||
github.com/guregu/null v3.4.0+incompatible
|
||||
github.com/gorilla/feeds v1.1.1
|
||||
github.com/gorilla/mux v1.7.4
|
||||
github.com/gorilla/schema v1.2.0
|
||||
github.com/gorilla/sessions v1.2.0
|
||||
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/jteeuwen/go-bindata v3.0.7+incompatible // indirect
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
||||
github.com/manifoldco/promptui v0.3.2
|
||||
github.com/mattn/go-colorable v0.1.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.10.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/manifoldco/promptui v0.7.0
|
||||
github.com/mattn/go-sqlite3 v1.14.2
|
||||
github.com/microcosm-cc/bluemonday v1.0.3
|
||||
github.com/mitchellh/go-wordwrap v1.0.0
|
||||
github.com/nicksnyder/go-i18n v1.10.0 // indirect
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||
github.com/pelletier/go-toml v1.2.0 // 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/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
||||
github.com/stretchr/testify v1.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/activityserve v0.0.0-20200409150223-d7ab3eaa4481
|
||||
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/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/nerds v1.0.0
|
||||
github.com/writeas/openssl-go v1.0.0 // indirect
|
||||
github.com/writeas/saturday v1.7.1
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
|
||||
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
|
||||
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/net v0.0.0-20190206173232-65e2d4e15006 // indirect
|
||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503 // indirect
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect
|
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
|
||||
google.golang.org/appengine v1.4.0 // indirect
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
|
||||
gopkg.in/ini.v1 v1.41.0
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||
gopkg.in/ini.v1 v1.57.0
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // 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/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 v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU=
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/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/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-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/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/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
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/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/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/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.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/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/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-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/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/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
|
||||
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
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/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY=
|
||||
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/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/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
|
||||
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
|
||||
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/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/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/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
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/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.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/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o=
|
||||
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-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/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/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/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/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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/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/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/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/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/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/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/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/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/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
||||
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-writeas v1.1.0 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/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
|
||||
github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
|
||||
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o=
|
||||
github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/import v0.2.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/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
|
||||
github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
|
||||
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
|
||||
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
|
||||
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
||||
github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
|
||||
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
||||
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
||||
github.com/writeas/web-core v1.0.0 h1:5VKkCakQgdKZcbfVKJXtRpc5VHrkflusCl/KRCPzpQ0=
|
||||
github.com/writeas/web-core v1.0.0/go.mod h1:Si3chV7VWgY8CsV+3gRolMXSO2Vx1ZFAQ/mkrpvmyEE=
|
||||
github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
|
||||
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
|
||||
github.com/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/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/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f h1:ETU2VEl7TnT5bl7IvuKEzTDpplg5wzGYsOCAPhdoEIg=
|
||||
golang.org/x/crypto v0.0.0-20190208162236-193df9c0f06f/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/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-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c=
|
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/net v0.0.0-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-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-20190206173232-65e2d4e15006 h1:bfLnR+k0tq5Lqt6dflRLcZiz6UaXCMt3vhYJ1l4FQ80=
|
||||
golang.org/x/net v0.0.0-20190206173232-65e2d4e15006/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
|
||||
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-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-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-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/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
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 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/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
|
||||
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ=
|
||||
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/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.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"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/prologic/go-gopher"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
|
@ -64,6 +65,7 @@ func UserLevelReader(cfg *config.Config) UserLevel {
|
|||
|
||||
type (
|
||||
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error
|
||||
gopherFunc func(app *App, w gopher.ResponseWriter, r *gopher.Request) error
|
||||
userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
||||
userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error
|
||||
dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
|
||||
|
@ -73,7 +75,7 @@ type (
|
|||
|
||||
type Handler struct {
|
||||
errors *ErrorPages
|
||||
sessionStore *sessions.CookieStore
|
||||
sessionStore sessions.Store
|
||||
app Apper
|
||||
}
|
||||
|
||||
|
@ -83,6 +85,7 @@ type ErrorPages struct {
|
|||
NotFound *template.Template
|
||||
Gone *template.Template
|
||||
InternalServerError *template.Template
|
||||
UnavailableError *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}}")),
|
||||
Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
|
||||
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
|
||||
UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>503</title></head><body><p>Service is temporarily unavailable.</p></body></html>{{end}}")),
|
||||
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
|
||||
},
|
||||
sessionStore: apper.App().sessionStore,
|
||||
sessionStore: apper.App().SessionStore(),
|
||||
app: apper,
|
||||
}
|
||||
|
||||
|
@ -111,6 +115,7 @@ func NewWFHandler(apper Apper) *Handler {
|
|||
NotFound: pages["404-general.tmpl"],
|
||||
Gone: pages["410.tmpl"],
|
||||
InternalServerError: pages["500.tmpl"],
|
||||
UnavailableError: pages["503.tmpl"],
|
||||
Blank: pages["blank.tmpl"],
|
||||
})
|
||||
return h
|
||||
|
@ -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 {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
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)))
|
||||
}()
|
||||
|
||||
// Allow any origin, as public endpoints are handled in here
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*");
|
||||
|
||||
if h.app.App().cfg.App.Private {
|
||||
// This instance is private, so ensure it's being accessed by a valid user
|
||||
// Check if authenticated with an access token
|
||||
|
@ -732,6 +771,10 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
log.Info("handleHTTPErorr internal error render")
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusServiceUnavailable {
|
||||
w.WriteHeader(err.Status)
|
||||
h.errors.UnavailableError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusAccepted {
|
||||
impart.WriteSuccess(w, "", err.Status)
|
||||
return
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
func (h *Handler) handleOAuthError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
if err.Status >= 300 && err.Status < 400 {
|
||||
sendRedirect(w, err.Status, err.Message)
|
||||
return
|
||||
}
|
||||
|
||||
impart.WriteOAuthError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
impart.WriteOAuthError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
|
||||
return
|
||||
}
|
||||
|
||||
func correctPageFromLoginAttempt(r *http.Request) string {
|
||||
to := r.FormValue("to")
|
||||
if to == "" {
|
||||
|
@ -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 {
|
||||
w.Header().Set("Location", location)
|
||||
w.WriteHeader(code)
|
||||
|
|
34
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.
|
||||
*
|
||||
|
@ -42,6 +42,18 @@ func (i Invite) Expired() bool {
|
|||
return i.Expires != nil && i.Expires.Before(time.Now())
|
||||
}
|
||||
|
||||
func (i Invite) Active(db *datastore) bool {
|
||||
if i.Expired() {
|
||||
return false
|
||||
}
|
||||
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
|
||||
if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (i Invite) ExpiresFriendly() string {
|
||||
return i.Expires.Format("January 2, 2006, 3:04 PM")
|
||||
}
|
||||
|
@ -56,12 +68,19 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
Invites *[]Invite
|
||||
Invites *[]Invite
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Invite People", f),
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -79,7 +98,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
|
|||
expVal := r.FormValue("expires")
|
||||
|
||||
if u.IsSilenced() {
|
||||
return ErrUserSuspended
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
var err error
|
||||
|
@ -151,18 +170,23 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Error string
|
||||
Flashes []template.HTML
|
||||
Invite string
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Invite: inviteCode,
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.cfg),
|
||||
Invite: inviteCode,
|
||||
}
|
||||
|
||||
if expired {
|
||||
p.Error = "This invite link has expired."
|
||||
}
|
||||
|
||||
// Tell search engines not to index invite links
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
|
||||
// Get error messages
|
||||
session, err := app.sessionStore.Get(r, cookieName)
|
||||
if err != nil {
|
||||
|
|
|
@ -13,19 +13,38 @@ nav#admin {
|
|||
display: block;
|
||||
margin: 0.5em 0;
|
||||
a {
|
||||
color: @primary;
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
margin-left: 0;
|
||||
.rounded(.25em);
|
||||
border: 0;
|
||||
&.selected {
|
||||
background: #dedede;
|
||||
font-weight: bold;
|
||||
.blip {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
.blip {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&:not(.pages) {
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
a {
|
||||
margin-left: 0;
|
||||
.rounded(.25em);
|
||||
|
||||
&+a {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
font-family: @sansFont;
|
||||
|
@ -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 "effects";
|
||||
@import "admin";
|
||||
@import "login";
|
||||
@import "pages/error";
|
||||
@import "lib/elements";
|
||||
@import "lib/material";
|
||||
|
|
129
less/core.less
|
@ -10,6 +10,8 @@
|
|||
@proSelectedCol: #71D571;
|
||||
@textLinkColor: rgb(0, 0, 238);
|
||||
|
||||
@accent: #767676;
|
||||
|
||||
body {
|
||||
font-family: @serifFont;
|
||||
font-size-adjust: 0.5;
|
||||
|
@ -81,7 +83,7 @@ body {
|
|||
font-size: 1.5em;
|
||||
}
|
||||
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;
|
||||
p {
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
textarea, pre, body#post article, body#collection article p {
|
||||
&.norm, &.sans, &.wrap {
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
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 {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
@ -684,18 +703,19 @@ select.inputform, textarea.inputform {
|
|||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
input, button, select.inputform, textarea.inputform {
|
||||
input, button, select.inputform, textarea.inputform, a.btn {
|
||||
padding: 0.5em;
|
||||
font-family: @serifFont;
|
||||
font-size: 100%;
|
||||
.rounded(.25em);
|
||||
&[type=submit], &.submit {
|
||||
&[type=submit], &.submit, &.cta {
|
||||
border: 1px solid @primary;
|
||||
background: @primary;
|
||||
color: white;
|
||||
.transition(0.2s);
|
||||
&:hover {
|
||||
background-color: lighten(@primary, 3%);
|
||||
text-decoration: none;
|
||||
}
|
||||
&:disabled {
|
||||
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 {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
@ -793,9 +825,6 @@ input {
|
|||
&.snug {
|
||||
max-width: 40em;
|
||||
}
|
||||
&.regular {
|
||||
font-size: 1em;
|
||||
}
|
||||
.app {
|
||||
+ .app {
|
||||
margin-top: 1.5em;
|
||||
|
@ -812,7 +841,7 @@ input {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
@ -867,20 +896,6 @@ input {
|
|||
text-align: center;
|
||||
}
|
||||
}
|
||||
div.features {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
font-size: 0.86em;
|
||||
ul {
|
||||
text-align: left;
|
||||
max-width: 26em;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
li.soon, span.soon {
|
||||
color: lighten(#111, 40%);
|
||||
}
|
||||
}
|
||||
}
|
||||
div.blurbs {
|
||||
>h2 {
|
||||
text-align: center;
|
||||
|
@ -964,7 +979,12 @@ footer.contain-me {
|
|||
}
|
||||
ul {
|
||||
&.collections {
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
li {
|
||||
&.collection {
|
||||
a.title {
|
||||
|
@ -1006,7 +1026,7 @@ footer.contain-me {
|
|||
}
|
||||
|
||||
li {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
.item-desc, .prog-lang {
|
||||
font-size: 0.6em;
|
||||
|
@ -1094,7 +1114,8 @@ body#pad-sub #posts, .atoms {
|
|||
}
|
||||
.electron {
|
||||
font-weight: normal;
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.86em;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
h3, h4 {
|
||||
|
@ -1244,7 +1265,7 @@ header {
|
|||
}
|
||||
}
|
||||
&.singleuser {
|
||||
margin: 0.5em 0.25em;
|
||||
margin: 0.5em 1em 0.5em 0.25em;
|
||||
nav#user-nav {
|
||||
nav > ul > li:first-child {
|
||||
img {
|
||||
|
@ -1252,6 +1273,9 @@ header {
|
|||
}
|
||||
}
|
||||
}
|
||||
.right-side {
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
}
|
||||
.dash-nav {
|
||||
font-weight: bold;
|
||||
|
@ -1317,6 +1341,24 @@ form {
|
|||
font-size: 0.86em;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
&.prominent {
|
||||
margin: 1em 0;
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
}
|
||||
select {
|
||||
font-size: 1em;
|
||||
padding: 0.5rem;
|
||||
display: block;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
div.row {
|
||||
display: flex;
|
||||
|
@ -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) {
|
||||
body#post {
|
||||
header {
|
||||
|
@ -1392,7 +1444,7 @@ div.row {
|
|||
}
|
||||
|
||||
@media all and (max-width: 600px) {
|
||||
div.row {
|
||||
div.row:not(.admin-actions) {
|
||||
flex-direction: column;
|
||||
}
|
||||
.half {
|
||||
|
@ -1518,3 +1570,26 @@ div.row {
|
|||
pre.code-block {
|
||||
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 {
|
||||
margin: 0;
|
||||
|
@ -58,7 +58,7 @@ header {
|
|||
}
|
||||
p {
|
||||
&.description {
|
||||
color: #666;
|
||||
color: #444;
|
||||
font-size: 1.1em;
|
||||
margin-top: 0.5em;
|
||||
line-height: 1.5;
|
||||
|
@ -113,7 +113,7 @@ textarea {
|
|||
ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1em;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
&.collections, &.posts, &.integrations {
|
||||
list-style: none;
|
||||
|
@ -127,7 +127,6 @@ textarea {
|
|||
&.collection {
|
||||
a.title {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -206,7 +205,7 @@ code, textarea#embed {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
&:hover {
|
||||
background: @lightNavHoverBG;
|
||||
}
|
||||
&:hover > ul {
|
||||
&:hover > ul, &.open > ul {
|
||||
display: block;
|
||||
}
|
||||
&.selected {
|
||||
|
@ -361,6 +361,24 @@ body#pad {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
body#pad .alert {
|
||||
position: fixed;
|
||||
bottom: 0.25em;
|
||||
left: 2em;
|
||||
right: 2em;
|
||||
font-size: 1.1em;
|
||||
|
||||
&#edited-elsewhere {
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-height: 500px) {
|
||||
body#pad {
|
||||
textarea {
|
||||
|
@ -425,6 +443,10 @@ body#pad {
|
|||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
}
|
||||
.alert {
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 60em) {
|
||||
|
@ -433,6 +455,10 @@ body#pad {
|
|||
padding-left: 15%;
|
||||
padding-right: 15%;
|
||||
}
|
||||
.alert {
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 70em) {
|
||||
|
@ -441,6 +467,10 @@ body#pad {
|
|||
padding-left: 20%;
|
||||
padding-right: 20%;
|
||||
}
|
||||
.alert {
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 85em) {
|
||||
|
@ -449,6 +479,10 @@ body#pad {
|
|||
padding-left: 25%;
|
||||
padding-right: 25%;
|
||||
}
|
||||
.alert {
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 105em) {
|
||||
|
@ -457,6 +491,10 @@ body#pad {
|
|||
padding-left: 30%;
|
||||
padding-right: 30%;
|
||||
}
|
||||
.alert {
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
|
|
|
@ -17,6 +17,16 @@ body {
|
|||
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;
|
||||
padding: 0 1em;
|
||||
margin: 0.5em;
|
||||
color: #777;
|
||||
color: #767676;
|
||||
display: inline-block;
|
||||
|
||||
p {
|
||||
|
@ -48,7 +58,7 @@ body#post article, pre, .hljs {
|
|||
}
|
||||
}
|
||||
.article-p() {
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
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"
|
||||
}
|
||||
|
||||
func (db *datastore) after(colName string) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
}
|
||||
return " AFTER " + colName
|
||||
}
|
||||
|
|
|
@ -56,9 +56,16 @@ func (m *migration) Migrate(db *datastore) error {
|
|||
}
|
||||
|
||||
var migrations = []Migration{
|
||||
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
|
||||
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
|
||||
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
|
||||
New("support user invites", supportUserInvites), // -> V1 (v0.8.0)
|
||||
New("support dynamic instance pages", supportInstancePages), // V1 -> V2 (v0.9.0)
|
||||
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
|
||||
New("support oauth", oauth), // V3 -> V4
|
||||
New("support slack oauth", oauthSlack), // V4 -> v5
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6
|
||||
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
|
||||
New("support post signatures", supportPostSignatures), // V9 -> V10
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
20
pad.go
|
@ -35,10 +35,10 @@ func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
appData := &struct {
|
||||
page.StaticPage
|
||||
Post *RawPost
|
||||
User *User
|
||||
Blogs *[]Collection
|
||||
Suspended bool
|
||||
Post *RawPost
|
||||
User *User
|
||||
Blogs *[]Collection
|
||||
Silenced bool
|
||||
|
||||
Editing bool // True if we're modifying an existing post
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
appData.EditCollection.hostName = app.cfg.App.Host
|
||||
} else {
|
||||
// Editing a floating article
|
||||
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
|
||||
Flashes []string
|
||||
NeedsToken bool
|
||||
Suspended bool
|
||||
Silenced bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Post: &RawPost{Font: "norm"},
|
||||
User: getUserSession(app, r),
|
||||
}
|
||||
var err error
|
||||
appData.Suspended, err = app.db.IsUserSuspended(appData.User.ID)
|
||||
appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
|
||||
if err != nil {
|
||||
log.Error("view meta: get user suspended status: %v", err)
|
||||
log.Error("view meta: get user status: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
|
||||
|
@ -161,6 +162,7 @@ func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appData.EditCollection.hostName = app.cfg.App.Host
|
||||
} else {
|
||||
// Editing a floating article
|
||||
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;
|
||||
max-width: 8em;
|
||||
}
|
||||
.or {
|
||||
margin-bottom: 2.5em !important;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{define "content"}}
|
||||
|
@ -73,6 +76,8 @@ form dd {
|
|||
|
||||
<div{{if not .OpenRegistration}} style="padding: 2em 0;"{{end}}>
|
||||
{{ if .OpenRegistration }}
|
||||
{{template "oauth-buttons" .}}
|
||||
{{if not .DisablePasswordAuth}}
|
||||
{{if .Flashes}}<ul class="errors">
|
||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||
</ul>{{end}}
|
||||
|
@ -101,6 +106,7 @@ form dd {
|
|||
</dl>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{ else }}
|
||||
<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>
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
{{define "head"}}<title>Log in — {{.SiteName}}</title>
|
||||
<meta name="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}}
|
||||
{{define "content"}}
|
||||
<div class="tight content-container">
|
||||
|
@ -11,6 +13,9 @@
|
|||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||
</ul>{{end}}
|
||||
|
||||
{{template "oauth-buttons" .}}
|
||||
|
||||
{{if not .DisablePasswordAuth}}
|
||||
<form action="/auth/login" method="post" style="text-align: center;margin-top:1em;" onsubmit="disableSubmit()">
|
||||
<input type="text" name="alias" placeholder="Username" value="{{.LoginUsername}}" {{if not .LoginUsername}}autofocus{{end}} /><br />
|
||||
<input type="password" name="pass" placeholder="Password" {{if .LoginUsername}}autofocus{{end}} /><br />
|
||||
|
@ -18,13 +23,14 @@
|
|||
<input type="submit" id="btn-login" value="Login" />
|
||||
</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">
|
||||
function disableSubmit() {
|
||||
var $btn = document.getElementById("btn-login");
|
||||
$btn.value = "Logging in...";
|
||||
$btn.disabled = true;
|
||||
}
|
||||
</script>
|
||||
<script type="text/javascript">
|
||||
function disableSubmit() {
|
||||
var $btn = document.getElementById("btn-login");
|
||||
$btn.value = "Logging in...";
|
||||
$btn.disabled = true;
|
||||
}
|
||||
</script>
|
||||
{{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}}
|
||||
|
||||
<div id="billing">
|
||||
{{template "oauth-buttons" .}}
|
||||
|
||||
{{if not .DisablePasswordAuth}}
|
||||
<form action="/auth/signup" method="POST" id="signup-form" onsubmit="return signup()">
|
||||
<input type="hidden" name="invite_code" value="{{.Invite}}" />
|
||||
<dl class="billing">
|
||||
|
@ -93,6 +96,7 @@ form dd {
|
|||
</dt>
|
||||
</dl>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,9 +11,11 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
@ -21,7 +23,9 @@ import (
|
|||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
"github.com/writeas/impart"
|
||||
blackfriday "github.com/writeas/saturday"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/stringmanip"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/parse"
|
||||
|
@ -34,6 +38,7 @@ var (
|
|||
titleElementReg = regexp.MustCompile("</?h[1-6]>")
|
||||
hashtagReg = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
|
||||
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) {
|
||||
|
@ -53,6 +58,17 @@ func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
|
|||
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 {
|
||||
return applyMarkdownSpecial(data, false, baseURL, cfg)
|
||||
}
|
||||
|
@ -82,6 +98,8 @@ func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *c
|
|||
tagPrefix = "/read/t/"
|
||||
}
|
||||
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
|
||||
policy := getSanitizationPolicy()
|
||||
|
@ -172,6 +190,7 @@ func getSanitizationPolicy() *bluemonday.Policy {
|
|||
policy.AllowAttrs("target").OnElements("a")
|
||||
policy.AllowAttrs("title").OnElements("abbr")
|
||||
policy.AllowAttrs("style", "class", "id").Globally()
|
||||
policy.AllowElements("header", "footer")
|
||||
policy.AllowURLSchemes("http", "https", "mailto", "xmpp")
|
||||
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)))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
191
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.
|
||||
*
|
||||
|
@ -16,6 +16,7 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -35,7 +36,6 @@ import (
|
|||
"github.com/writeas/web-core/i18n"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/tags"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writeas/writefreely/parse"
|
||||
)
|
||||
|
@ -63,6 +63,7 @@ type (
|
|||
Description string
|
||||
Author string
|
||||
Views int64
|
||||
Images []string
|
||||
IsPlainText bool
|
||||
IsCode bool
|
||||
IsLinkable bool
|
||||
|
@ -134,6 +135,7 @@ type (
|
|||
Views int64
|
||||
Font string
|
||||
Created time.Time
|
||||
Updated time.Time
|
||||
IsRTL sql.NullBool
|
||||
Language sql.NullString
|
||||
OwnerID int64
|
||||
|
@ -229,6 +231,10 @@ func (p Post) Summary() string {
|
|||
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.
|
||||
// TODO: use HTMLExcerpt in templates instead of this method
|
||||
func (p *Post) Excerpt() template.HTML {
|
||||
|
@ -378,13 +384,16 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
if !isRaw {
|
||||
post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
|
||||
post.Images = extractImages(post.Content)
|
||||
}
|
||||
}
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(ownerID.Int64)
|
||||
if err != nil {
|
||||
log.Error("view post: %v", err)
|
||||
return ErrInternalGeneral
|
||||
var silenced bool
|
||||
if found {
|
||||
silenced, err = app.db.IsUserSilenced(ownerID.Int64)
|
||||
if err != nil {
|
||||
log.Error("view post: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if post has been unpublished
|
||||
|
@ -434,10 +443,10 @@ func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
page := struct {
|
||||
*AnonymousPost
|
||||
page.StaticPage
|
||||
Username string
|
||||
IsOwner bool
|
||||
SiteURL string
|
||||
Suspended bool
|
||||
Username string
|
||||
IsOwner bool
|
||||
SiteURL string
|
||||
Silenced bool
|
||||
}{
|
||||
AnonymousPost: post,
|
||||
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
|
||||
}
|
||||
|
||||
if !page.IsOwner && suspended {
|
||||
if !page.IsOwner && silenced {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
page.Suspended = suspended
|
||||
page.Silenced = silenced
|
||||
err = templates["post"].ExecuteTemplate(w, "post", page)
|
||||
if err != nil {
|
||||
log.Error("Post template execute error: %v", err)
|
||||
|
@ -508,13 +517,12 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
} else {
|
||||
userID = app.db.GetUserID(accessToken)
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(userID)
|
||||
silenced, err := app.db.IsUserSilenced(userID)
|
||||
if err != nil {
|
||||
log.Error("new post: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Error("existing post: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
// Modify post struct
|
||||
|
@ -885,13 +892,12 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
ownerID = u.ID
|
||||
}
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(ownerID)
|
||||
silenced, err := app.db.IsUserSilenced(ownerID)
|
||||
if err != nil {
|
||||
log.Error("add post: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
// Parse claimed posts in format:
|
||||
|
@ -988,13 +994,12 @@ func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
userID = u.ID
|
||||
}
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(userID)
|
||||
silenced, err := app.db.IsUserSilenced(userID)
|
||||
if err != nil {
|
||||
log.Error("pin post: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
if suspended {
|
||||
return ErrUserSuspended
|
||||
if silenced {
|
||||
return ErrUserSilenced
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var collID int64
|
||||
var ownerID int64
|
||||
var coll *Collection
|
||||
var err error
|
||||
vars := mux.Vars(r)
|
||||
|
@ -1049,26 +1053,33 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coll.hostName = app.cfg.App.Host
|
||||
_, err = apiCheckCollectionPermissions(app, r, coll)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collID = coll.ID
|
||||
ownerID = coll.OwnerID
|
||||
}
|
||||
|
||||
p, err := app.db.GetPost(vars["post"], collID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suspended, err := app.db.IsUserSuspended(ownerID)
|
||||
if err != nil {
|
||||
log.Error("fetch post: %v", err)
|
||||
return ErrInternalGeneral
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -1076,13 +1087,6 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
accept := r.Header.Get("Accept")
|
||||
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 {
|
||||
// This is a draft post; 404 for now
|
||||
// TODO: return ActivityObject
|
||||
|
@ -1090,8 +1094,9 @@ func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
|
||||
p.Collection = &CollectionObj{Collection: *coll}
|
||||
po := p.ActivityObject(app.cfg)
|
||||
po := p.ActivityObject(app)
|
||||
po.Context = []interface{}{activitystreams.Namespace}
|
||||
setCacheControl(w, apCacheTime)
|
||||
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
|
||||
}
|
||||
|
||||
func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object {
|
||||
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
|
||||
cfg := app.cfg
|
||||
var o *activitystreams.Object
|
||||
if strings.Index(p.Content, "\n\n") == -1 {
|
||||
o = activitystreams.NewNoteObject()
|
||||
|
@ -1140,6 +1146,7 @@ func (p *PublicPost) ActivityObject(cfg *config.Config) *activitystreams.Object
|
|||
p.Collection.FederatedAccount() + "/followers",
|
||||
}
|
||||
o.Name = p.DisplayTitle()
|
||||
p.augmentContent()
|
||||
if p.HTMLContent == template.HTML("") {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -1220,9 +1247,9 @@ func getRawPost(app *App, friendlyID string) *RawPost {
|
|||
var isRTL sql.NullBool
|
||||
var lang sql.NullString
|
||||
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 {
|
||||
case err == sql.ErrNoRows:
|
||||
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{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 isRTL sql.NullBool
|
||||
var lang sql.NullString
|
||||
var created time.Time
|
||||
var created, updated time.Time
|
||||
var ownerID null.Int
|
||||
var views int64
|
||||
var err error
|
||||
|
||||
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 {
|
||||
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 {
|
||||
case err == sql.ErrNoRows:
|
||||
|
@ -1263,6 +1290,7 @@ func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
|
|||
Content: content,
|
||||
Font: font,
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
IsRTL: isRTL,
|
||||
Language: lang,
|
||||
OwnerID: ownerID.Int64,
|
||||
|
@ -1337,18 +1365,21 @@ func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
suspended, err := app.db.IsUserSuspended(c.OwnerID)
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("view collection post: %v", err)
|
||||
return ErrInternalGeneral
|
||||
}
|
||||
|
||||
// Check collection permissions
|
||||
if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
|
||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
|
||||
coll := &CollectionObj{Collection: *c}
|
||||
coll := NewCollectionObj(c)
|
||||
owner, err := app.db.GetUserByID(coll.OwnerID)
|
||||
if err != nil {
|
||||
// Log the error and just continue
|
||||
|
@ -1399,7 +1430,7 @@ Are you sure it was ever here?`,
|
|||
p.Collection = coll
|
||||
p.IsTopLevel = app.cfg.App.SingleUser
|
||||
|
||||
if !p.IsOwner && suspended {
|
||||
if !p.IsOwner && silenced {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
// 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."}
|
||||
}
|
||||
|
||||
p.augmentContent()
|
||||
|
||||
// Serve collection post
|
||||
if isRaw {
|
||||
contentType := "text/plain"
|
||||
|
@ -1433,8 +1466,9 @@ Are you sure it was ever here?`,
|
|||
return ErrCollectionPageNotFound
|
||||
}
|
||||
p.extractData()
|
||||
ap := p.ActivityObject(app.cfg)
|
||||
ap := p.ActivityObject(app)
|
||||
ap.Context = []interface{}{activitystreams.Namespace}
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ap, http.StatusOK)
|
||||
} else {
|
||||
p.extractData()
|
||||
|
@ -1451,14 +1485,14 @@ Are you sure it was ever here?`,
|
|||
IsFound bool
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
Suspended bool
|
||||
Silenced bool
|
||||
}{
|
||||
PublicPost: p,
|
||||
StaticPage: pageForReq(app, r),
|
||||
IsOwner: cr.isCollOwner,
|
||||
IsCustomDomain: cr.isCustomDomain,
|
||||
IsFound: postFound,
|
||||
Suspended: suspended,
|
||||
Silenced: silenced,
|
||||
}
|
||||
tp.IsAdmin = u != nil && u.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")
|
||||
}
|
||||
|
||||
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() {
|
||||
matches := extract.ExtractUrls(p.Content)
|
||||
p.Images = extractImages(p.Content)
|
||||
}
|
||||
|
||||
func extractImages(content string) []string {
|
||||
matches := extract.ExtractUrls(content)
|
||||
urls := map[string]bool{}
|
||||
for i := range matches {
|
||||
u := matches[i].Text
|
||||
if !imageURLRegex.MatchString(u) {
|
||||
uRaw := matches[i].Text
|
||||
// Parse the extracted text so we can examine the path
|
||||
u, err := url.Parse(uRaw)
|
||||
if err != nil {
|
||||
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)
|
||||
for k := range urls {
|
||||
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.
|
||||
*
|
||||
|
@ -33,6 +33,8 @@ const (
|
|||
tlAPIPageLimit = 10
|
||||
tlMaxAuthorPosts = 5
|
||||
tlPostsPerPage = 16
|
||||
tlMaxPostCache = 250
|
||||
tlCacheDur = 10 * time.Minute
|
||||
)
|
||||
|
||||
type localTimeline struct {
|
||||
|
@ -60,19 +62,25 @@ type readPublication struct {
|
|||
func initLocalTimeline(app *App) {
|
||||
app.timeline = &localTimeline{
|
||||
postsPerPage: tlPostsPerPage,
|
||||
m: memo.New(app.FetchPublicPosts, 10*time.Minute),
|
||||
m: memo.New(app.FetchPublicPosts, tlCacheDur),
|
||||
}
|
||||
}
|
||||
|
||||
// satisfies memo.Func
|
||||
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
|
||||
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
|
||||
LEFT JOIN posts p ON p.collection_id = c.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
|
||||
ORDER BY p.created DESC`)
|
||||
WHERE c.privacy = 1 AND (p.created <= ` + app.db.now() + ` AND pinned_position IS NULL) AND u.status = 0
|
||||
ORDER BY p.created DESC
|
||||
` + limit)
|
||||
if err != nil {
|
||||
log.Error("Failed selecting from posts: %v", err)
|
||||
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(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
|
||||
// Handle auth
|
||||
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("/export", handler.User(viewExportOptions)).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("/invites", handler.User(handleViewUserInvites)).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("/self", handler.All(updateSettings)).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
|
||||
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
|
||||
|
||||
write.HandleFunc("/api/markdown", handler.All(handleRenderMarkdown)).Methods("POST")
|
||||
|
||||
// Handle collections
|
||||
write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
|
||||
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("/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/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
|
||||
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/update/config", handler.AdminApper(handleAdminUpdateConfig)).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
|
||||
write.HandleFunc("/login", handler.Web(viewLogin, 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
|
||||
write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
|
||||
RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
|
||||
|
@ -162,14 +179,14 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
|||
draftEditPrefix := ""
|
||||
if apper.App().cfg.App.SingleUser {
|
||||
draftEditPrefix = "/d"
|
||||
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
|
||||
write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
|
||||
} else {
|
||||
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
|
||||
write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
|
||||
}
|
||||
|
||||
// All the existing stuff
|
||||
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
|
||||
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET")
|
||||
write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelUser)).Methods("GET")
|
||||
write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelUser)).Methods("GET")
|
||||
// Collections
|
||||
if apper.App().cfg.App.SingleUser {
|
||||
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("/", handler.Web(handleViewHome, UserLevelOptional))
|
||||
|
||||
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. ##
|
||||
###############################################################################
|
||||
#
|
||||
# Copyright © 2019 A Bunch Tell LLC.
|
||||
# Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
#
|
||||
# This file is part of WriteFreely.
|
||||
#
|
||||
|
@ -31,7 +31,7 @@ fi
|
|||
# go ahead and check for the latest release on linux
|
||||
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
|
||||
|
||||
|
@ -82,13 +82,25 @@ filename=${parts[-1]}
|
|||
echo "Extracting files..."
|
||||
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
|
||||
echo "Copying files..."
|
||||
cp -r $tempdir/{pages,static,templates,writefreely} .
|
||||
cp -r $tempdir/writefreely/{pages,static,templates,writefreely} .
|
||||
|
||||
# migrate db
|
||||
./writefreely -migrate
|
||||
|
||||
# restart service
|
||||
echo "Restarting writefreely systemd service..."
|
||||
if `systemctl restart writefreely`; then
|
||||
echo "Starting writefreely systemd service..."
|
||||
if `systemctl start writefreely`; then
|
||||
echo "Success, version has been upgraded to $latest."
|
||||
else
|
||||
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) {
|
||||
localStorage.setItem(key, $el.el.value);
|
||||
},
|
||||
load: function($el, key, onlyLoadPopulated) {
|
||||
load: function($el, key, onlyLoadPopulated, postUpdated) {
|
||||
var val = localStorage.getItem(key);
|
||||
if (onlyLoadPopulated && val == null) {
|
||||
// Do nothing
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
$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) {
|
||||
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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
|
@ -37,6 +38,10 @@ var (
|
|||
"localstr": localStr,
|
||||
"localhtml": localHTML,
|
||||
"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, "include", "footer.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" {
|
||||
// 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)
|
||||
}
|
||||
|
||||
pages[key] = template.Must(template.New("").Funcs(funcMap).ParseFiles(
|
||||
files := []string{
|
||||
path,
|
||||
filepath.Join(parentDir, templatesDir, "include", "footer.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) {
|
||||
|
@ -101,7 +112,8 @@ func initUserPage(parentDir, path, key string) {
|
|||
path,
|
||||
filepath.Join(parentDir, templatesDir, "user", "include", "header.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)
|
||||
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>
|
||||
|
||||
<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">
|
||||
<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}}
|
||||
|
@ -36,6 +38,7 @@
|
|||
<script>
|
||||
var $writer = H.getEl('writer');
|
||||
var $btnPublish = H.getEl('publish');
|
||||
var $btnEraseEdit = H.getEl('edited-elsewhere');
|
||||
var $wc = H.getEl("wc");
|
||||
var updateWordCount = function() {
|
||||
var words = 0;
|
||||
|
@ -58,7 +61,17 @@
|
|||
};
|
||||
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
|
||||
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();
|
||||
|
||||
var typingTimer;
|
||||
|
@ -130,6 +143,7 @@
|
|||
data = JSON.parse(http.responseText);
|
||||
id = data.data.id;
|
||||
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
|
||||
localStorage.setItem('draft'+id+'-published', new Date().toISOString());
|
||||
|
||||
{{ if not .Post.Id }}
|
||||
// Post created
|
||||
|
@ -198,6 +212,13 @@
|
|||
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 = {
|
||||
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
|
||||
|
@ -207,12 +228,20 @@
|
|||
var doneTyping = function() {
|
||||
if (draftDoc == 'lastDoc' || $writer.el.value != origDoc) {
|
||||
H.save($writer, draftDoc);
|
||||
if (!defaultTimeSet) {
|
||||
var lastLocalPublishStr = localStorage.getItem(draftDoc+'-published');
|
||||
if (lastLocalPublishStr == null || lastLocalPublishStr == '') {
|
||||
localStorage.setItem(draftDoc+'-published', updatedStr);
|
||||
}
|
||||
defaultTimeSet = true;
|
||||
}
|
||||
updateWordCount();
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (draftDoc != 'lastDoc' && $writer.el.value == origDoc) {
|
||||
H.remove(draftDoc);
|
||||
H.remove(draftDoc+'-published');
|
||||
} else if (!justPublished) {
|
||||
doneTyping();
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<nav id="user-nav">
|
||||
{{if .Username}}
|
||||
<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}}
|
||||
<li><a href="/me/settings">Account settings</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}}
|
||||
{{ 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 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 eq .SignupPath "/signup"}}<a href="/signup"{{if eq .Path "/signup"}} class="selected"{{end}}>Sign up</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 }}
|
||||
</nav>
|
||||
{{if .Chorus}}{{if .Username}}<div class="right-side" style="font-size: 0.86em;">
|
||||
|
@ -67,6 +67,7 @@
|
|||
{{ template "footer" . }}
|
||||
|
||||
{{if not .JSDisabled}}
|
||||
<script type="text/javascript" src="/js/menu.js"></script>
|
||||
<script type="text/javascript">
|
||||
{{if .WebFonts}}
|
||||
try { // Google Fonts
|
||||
|
|
|
@ -37,16 +37,6 @@ body footer {
|
|||
}
|
||||
body#post header {
|
||||
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>
|
||||
|
||||
|
@ -65,10 +55,10 @@ article time.dt-published {
|
|||
|
||||
{{template "user-navigation" .}}
|
||||
|
||||
{{if .Suspended}}
|
||||
{{template "user-suspended"}}
|
||||
{{if .Silenced}}
|
||||
{{template "user-silenced"}}
|
||||
{{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 }}
|
||||
<footer dir="ltr">
|
||||
|
@ -93,6 +83,7 @@ article time.dt-published {
|
|||
{{range .Collection.ExternalScripts}}<script type="text/javascript" src="{{.}}" async></script>{{end}}
|
||||
{{if .Collection.Script}}<script type="text/javascript">{{.Collection.ScriptDisplay}}</script>{{end}}
|
||||
{{end}}
|
||||
<script src="/js/localdate.js"></script>
|
||||
<script type="text/javascript">
|
||||
|
||||
var pinning = false;
|
||||
|
|
|
@ -61,8 +61,8 @@ body#collection header nav.tabs a:first-child {
|
|||
<body id="collection" itemscope itemtype="http://schema.org/WebPage">
|
||||
{{template "user-navigation" .}}
|
||||
|
||||
{{if .Suspended}}
|
||||
{{template "user-suspended"}}
|
||||
{{if .Silenced}}
|
||||
{{template "user-silenced"}}
|
||||
{{end}}
|
||||
<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>
|
||||
|
@ -89,8 +89,8 @@ body#collection header nav.tabs a:first-child {
|
|||
{{template "posts" .}}
|
||||
|
||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||
{{if or (and .Format.Ascending (lt .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||
{{if or (and .Format.Ascending (le .CurrentPage .TotalPages)) (isRTL .Direction)}}
|
||||
{{if gt .CurrentPage 1}}<a href="{{.PrevPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ {{if and .Format.Ascending (le .CurrentPage .TotalPages)}}Previous{{else}}Newer{{end}}</a>{{end}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a style="float:right;" href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">{{if and .Format.Ascending (lt .CurrentPage .TotalPages)}}Next{{else}}Older{{end}} ⇢</a>{{end}}
|
||||
{{else}}
|
||||
{{if lt .CurrentPage .TotalPages}}<a href="{{.NextPageURL .Prefix .CurrentPage .IsTopLevel}}">⇠ Older</a>{{end}}
|
||||
|
@ -115,6 +115,7 @@ body#collection header nav.tabs a:first-child {
|
|||
{{if .Script}}<script type="text/javascript">{{.ScriptDisplay}}</script>{{end}}
|
||||
{{end}}
|
||||
<script src="/js/h.js"></script>
|
||||
<script src="/js/localdate.js"></script>
|
||||
<script src="/js/postactions.js"></script>
|
||||
<script type="text/javascript">
|
||||
var deleting = false;
|
||||
|
|