diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ea38748..30ec4bb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,99 +1,99 @@
 # Contributing to WriteFreely
 
 Welcome! We're glad you're interested in contributing to WriteFreely.
 
 For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
 
-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).
+For **bug reports**, please [open a GitHub issue](https://github.com/writefreely/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs).
 
 ## Getting Started
 
 There are many ways to contribute to WriteFreely, from code to documentation, to translations, to help in the community!
 
 See our [Contributing Guide](https://writefreely.org/contribute) on WriteFreely.org for ways to contribute without writing code. Otherwise, please read on.
 
 ## Working on WriteFreely
 
 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.
 
 ### Starting development
 
 Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://phabricator.write.as/tag/write_freely/) to see where the project is today and where it's headed.
 
 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)
+* [Rename Suspend status to Silence](https://github.com/writefreely/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e)
+* [Show 404 when remote user not found](https://github.com/writefreely/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d)
+* [Fix post deletion on Pleroma](https://github.com/writefreely/writefreely/commit/fe82cbb96e3d5c57cfde0db76c28c4ea6dabfe50)
 
 ### Submitting pull requests
 
 Like our GitHub issues, we aim to keep our number of open pull requests to a minimum. You can follow a few guidelines to ensure changes are merged quickly.
 
 First, make sure your changes follow the established practices and good form outlined in this guide. This is crucial to our project, and ignoring our practices can delay otherwise important fixes.
 
 Beyond that, we prioritize pull requests in this order:
 
 1. Fixes to open GitHub issues
 2. Superficial changes and improvements that don't adversely impact users
 3. New features and changes that have been discussed before with the team
 
 Any pull requests that haven't previously been discussed with the team may be extensively delayed or closed, especially if they require a wider consideration before integrating into the project. When in doubt, please reach out [on the forum](https://discuss.write.as) before submitting a pull request.
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index f4b5a0d..1021ec4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,37 +1,37 @@
 # Build image
 FROM golang:1.14-alpine as build
 
 RUN apk add --update nodejs nodejs-npm make g++ git
 RUN npm install -g less less-plugin-clean-css
 RUN go get -u github.com/go-bindata/go-bindata/...
 
-RUN mkdir -p /go/src/github.com/writeas/writefreely
-WORKDIR /go/src/github.com/writeas/writefreely
+RUN mkdir -p /go/src/github.com/writefreely/writefreely
+WORKDIR /go/src/github.com/writefreely/writefreely
 
 COPY . .
 
 ENV GO111MODULE=on
 
 RUN make build \
   && make ui
 RUN mkdir /stage && \
     cp -R /go/bin \
-      /go/src/github.com/writeas/writefreely/templates \
-      /go/src/github.com/writeas/writefreely/static \
-      /go/src/github.com/writeas/writefreely/pages \
-      /go/src/github.com/writeas/writefreely/keys \
-      /go/src/github.com/writeas/writefreely/cmd \
+      /go/src/github.com/writefreely/writefreely/templates \
+      /go/src/github.com/writefreely/writefreely/static \
+      /go/src/github.com/writefreely/writefreely/pages \
+      /go/src/github.com/writefreely/writefreely/keys \
+      /go/src/github.com/writefreely/writefreely/cmd \
       /stage
 
 # Final image
 FROM alpine:3.12
 
 RUN apk add --no-cache openssl ca-certificates
 COPY --from=build --chown=daemon:daemon /stage /go
 
 WORKDIR /go
 VOLUME /go/keys
 EXPOSE 8080
 USER daemon
 
 ENTRYPOINT ["cmd/writefreely/writefreely"]
diff --git a/Makefile b/Makefile
index a240a27..663faf6 100644
--- a/Makefile
+++ b/Makefile
@@ -1,171 +1,171 @@
 GITREV=`git describe | cut -c 2-`
-LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'"
+LDFLAGS=-ldflags="-X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)'"
 
 GOCMD=go
 GOINSTALL=$(GOCMD) install $(LDFLAGS)
 GOBUILD=$(GOCMD) build $(LDFLAGS)
 GOTEST=$(GOCMD) test $(LDFLAGS)
 GOGET=$(GOCMD) get
 BINARY_NAME=writefreely
 BUILDPATH=build/$(BINARY_NAME)
 DOCKERCMD=docker
 IMAGE_NAME=writeas/writefreely
 TMPBIN=./tmp
 
 all : build
 
 ci: ci-assets deps
 	cd cmd/writefreely; $(GOBUILD) -v
 
 build: assets deps
 	cd cmd/writefreely; $(GOBUILD) -v -tags='sqlite'
 
 build-no-sqlite: assets-no-sqlite deps-no-sqlite
 	cd cmd/writefreely; $(GOBUILD) -v -o $(BINARY_NAME)
 
 build-linux: deps
 	@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
 		$(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 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 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 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) .
 
 test:
 	$(GOTEST) -v ./...
 
 run: dev-assets
 	$(GOINSTALL) -tags='sqlite' ./...
 	$(BINARY_NAME) --debug
 
 deps :
 	$(GOGET) -tags='sqlite' -d -v ./...
 
 deps-no-sqlite:
 	$(GOGET) -d -v ./...
 
 install : build
 	cmd/writefreely/$(BINARY_NAME) --config
 	cmd/writefreely/$(BINARY_NAME) --gen-keys
 	cmd/writefreely/$(BINARY_NAME) --init-db
 	cd less/; $(MAKE) install $(MFLAGS)
 
 release : clean ui assets
 	mkdir -p $(BUILDPATH)
 	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)
 	rm $(BUILDPATH)/$(BINARY_NAME)
 	$(MAKE) build-windows
 	mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
 	cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
 	rm $(BUILDPATH)/$(BINARY_NAME)
 	$(MAKE) build-docker
 	$(MAKE) release-docker
 
 # This assumes you're on linux/amd64
 release-linux : clean ui
 	mkdir -p $(BUILDPATH)
 	cp -r templates $(BUILDPATH)
 	cp -r pages $(BUILDPATH)
 	cp -r static $(BUILDPATH)
 	mkdir $(BUILDPATH)/keys
 	$(MAKE) build-no-sqlite
 	mv cmd/writefreely/$(BINARY_NAME) $(BUILDPATH)/$(BINARY_NAME)
 	tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_amd64.tar.gz -C build $(BINARY_NAME)
 
 release-docker :
 	$(DOCKERCMD) push $(IMAGE_NAME)
 
 ui : force_look
 	cd less/; $(MAKE) $(MFLAGS)
 	cd prose/; $(MAKE) $(MFLAGS)
 
 assets : generate
 	go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
 
 assets-no-sqlite: generate
 	go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql
 
 dev-assets : generate
 	go-bindata -pkg writefreely -ignore=\\.gitignore -debug -tags="!wflib" schema.sql sqlite.sql
 
 lib-assets : generate
 	go-bindata -pkg writefreely -ignore=\\.gitignore -o bindata-lib.go -tags="wflib" schema.sql
 
 generate :
 	@hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
 		$(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \
 	fi
 
 $(TMPBIN):
 	mkdir -p $(TMPBIN)
 
 $(TMPBIN)/go-bindata: deps $(TMPBIN)
 	$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
 
 $(TMPBIN)/xgo: deps $(TMPBIN)
 	$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
 
 ci-assets : $(TMPBIN)/go-bindata
 	$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
 
 clean :
 	-rm -rf build
 	-rm -rf tmp
 	cd less/; $(MAKE) clean $(MFLAGS)
 
 force_look : 
 	true
diff --git a/README.md b/README.md
index 163eab7..02c8300 100644
--- a/README.md
+++ b/README.md
@@ -1,89 +1,89 @@
  
 
 	
 
 
 
-	
+	
 		
 	
 	
 		
 	
-	
+	
 		
 	
-	
-		
+	
+		
 	
 	
 		
 	
 
  
 
 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.
 
 
 
 [Try the writing experience](https://write.as/new)
 
 [Find an instance](https://writefreely.org/instances)
 
 ## Features
 
 ### Made for writing
 
 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.
 
 ### A connected community
 
 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.
 
 ### Intuitive organization
 
 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.
 
 ### 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.
 
 
 
 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 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!
 
-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.
+For common platforms, start with our [pre-built binaries](https://github.com/writefreely/writefreely/releases/) and head over to our [installation guide](https://writefreely.org/start) to get started.
 
 ### Packages
 
 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) —️ and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
 
 ## Development
 
 Start hacking on WriteFreely with our [developer setup guide](https://writefreely.org/docs/latest/developer/setup). For Docker support, see our [Docker guide](https://writefreely.org/docs/latest/admin/docker).
 
 ## Contributing
 
-We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writeas/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
+We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writefreely/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
 
-Before contributing anything, please read our [Contributing Guide](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
+Before contributing anything, please read our [Contributing Guide](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
 
 ## License
 
-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).
+Copyright © 2018-2021 [A Bunch Tell LLC](https://abunchtell.com) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE).
diff --git a/account.go b/account.go
index 9b90942..ba3c391 100644
--- a/account.go
+++ b/account.go
@@ -1,1180 +1,1179 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"encoding/json"
 	"fmt"
 	"html/template"
 	"net/http"
 	"regexp"
 	"strings"
 	"sync"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/gorilla/sessions"
 	"github.com/guregu/null/zero"
 	"github.com/writeas/impart"
 	"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"
+	"github.com/writefreely/writefreely/author"
+	"github.com/writefreely/writefreely/config"
+	"github.com/writefreely/writefreely/page"
 )
 
 type (
 	userSettings struct {
 		Username string `schema:"username" json:"username"`
 		Email    string `schema:"email" json:"email"`
 		NewPass  string `schema:"new-pass" json:"new_pass"`
 		OldPass  string `schema:"current-pass" json:"current_pass"`
 		IsLogOut bool   `schema:"logout" json:"logout"`
 	}
 
 	UserPage struct {
 		page.StaticPage
 
 		PageTitle string
 		Separator template.HTML
 		IsAdmin   bool
 		CanInvite bool
 		CollAlias string
 	}
 )
 
 func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage {
 	up := &UserPage{
 		StaticPage: pageForReq(app, r),
 		PageTitle:  title,
 	}
 	up.Username = u.Username
 	up.Flashes = flashes
 	up.Path = r.URL.Path
 	up.IsAdmin = u.IsAdmin()
 	up.CanInvite = canUserInvite(app.cfg, up.IsAdmin)
 	return up
 }
 
 func canUserInvite(cfg *config.Config, isAdmin bool) bool {
 	return cfg.App.UserInvites != "" &&
 		(isAdmin || cfg.App.UserInvites != "admin")
 }
 
 func (up *UserPage) SetMessaging(u *User) {
 	// up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
 }
 
 const (
 	loginAttemptExpiration = 3 * time.Second
 )
 
 var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead")
 
 func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
 	_, err := signup(app, w, r)
 	return err
 }
 
 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
 	var ur userRegistration
 	if reqJSON {
 		decoder := json.NewDecoder(r.Body)
 		err := decoder.Decode(&ur)
 		if err != nil {
 			log.Error("Couldn't parse signup JSON request: %v\n", err)
 			return nil, ErrBadJSON
 		}
 	} else {
 		// Check if user is already logged in
 		u := getUserSession(app, r)
 		if u != nil {
 			return &AuthUser{User: u}, nil
 		}
 
 		err := r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse signup form request: %v\n", err)
 			return nil, ErrBadFormData
 		}
 
 		err = app.formDecoder.Decode(&ur, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode signup form request: %v\n", err)
 			return nil, ErrBadFormData
 		}
 	}
 
 	return signupWithRegistration(app, ur, w, r)
 }
 
 func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
 	reqJSON := IsJSON(r)
 
 	// Validate required params (alias)
 	if signup.Alias == "" {
 		return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."}
 	}
 	if signup.Pass == "" {
 		return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."}
 	}
 	var desiredUsername string
 	if signup.Normalize {
 		// With this option we simply conform the username to what we expect
 		// without complaining. Since they might've done something funny, like
 		// enter: write.as/Way Out There, we'll use their raw input for the new
 		// collection name and sanitize for the slug / username.
 		desiredUsername = signup.Alias
 		signup.Alias = getSlug(signup.Alias, "")
 	}
 	if !author.IsValidUsername(app.cfg, signup.Alias) {
 		// Ensure the username is syntactically correct.
 		return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."}
 	}
 
 	// Handle empty optional params
 	hashedPass, err := auth.HashPass([]byte(signup.Pass))
 	if err != nil {
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
 	}
 
 	// Create struct to insert
 	u := &User{
 		Username:   signup.Alias,
 		HashedPass: hashedPass,
 		HasPass:    true,
 		Email:      prepareUserEmail(signup.Email, app.keys.EmailKey),
 		Created:    time.Now().Truncate(time.Second).UTC(),
 	}
 
 	// Create actual user
 	if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
 		return nil, err
 	}
 
 	// Log invite if needed
 	if signup.InviteCode != "" {
 		err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
 		if err != nil {
 			return nil, err
 		}
 	}
 
 	// Add back unencrypted data for response
 	if signup.Email != "" {
 		u.Email.String = signup.Email
 	}
 
 	resUser := &AuthUser{
 		User: u,
 	}
 	title := signup.Alias
 	if signup.Normalize {
 		title = desiredUsername
 	}
 	resUser.Collections = &[]Collection{
 		{
 			Alias: signup.Alias,
 			Title: title,
 		},
 	}
 
 	var token string
 	if reqJSON && !signup.Web {
 		token, err = app.db.GetAccessToken(u.ID)
 		if err != nil {
 			return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
 		}
 		resUser.AccessToken = token
 	} else {
 		session, err := app.sessionStore.Get(r, cookieName)
 		if err != nil {
 			// The cookie should still save, even if there's an error.
 			// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
 			log.Error("Session: %v; ignoring", err)
 		}
 		session.Values[cookieUserVal] = resUser.User.Cookie()
 		err = session.Save(r, w)
 		if err != nil {
 			log.Error("Couldn't save session: %v", err)
 			return nil, err
 		}
 	}
 	if reqJSON {
 		return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated)
 	}
 
 	return resUser, nil
 }
 
 func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error {
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		return ErrInternalCookieSession
 	}
 
 	// Ensure user has an email or password set before they go, so they don't
 	// lose access to their account.
 	val := session.Values[cookieUserVal]
 	var u = &User{}
 	var ok bool
 	if u, ok = val.(*User); !ok {
 		log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values)
 
 		err = session.Save(r, w)
 		if err != nil {
 			log.Error("Couldn't save session on logout: %v", err)
 			return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
 		}
 
 		return impart.HTTPError{http.StatusFound, "/"}
 	}
 
 	u, err = app.db.GetUserByID(u.ID)
 	if err != nil && err != ErrUserNotFound {
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."}
 	}
 
 	session.Options.MaxAge = -1
 
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Couldn't save session on logout: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
 	}
 
 	return impart.HTTPError{http.StatusFound, "/"}
 }
 
 func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error {
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" {
 		return ErrNoAccessToken
 	}
 	t := auth.GetToken(accessToken)
 	if len(t) == 0 {
 		return ErrNoAccessToken
 	}
 	err := app.db.DeleteToken(t)
 	if err != nil {
 		return err
 	}
 	return impart.HTTPError{Status: http.StatusNoContent}
 }
 
 func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
 	var earlyError string
 	oneTimeToken := r.FormValue("with")
 	if oneTimeToken != "" {
 		log.Info("Calling login with one-time token.")
 		err := login(app, w, r)
 		if err != nil {
 			log.Info("Received error: %v", err)
 			earlyError = fmt.Sprintf("%s", err)
 		}
 	}
 
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		// Ignore this
 		log.Error("Unable to get session; ignoring: %v", err)
 	}
 
 	p := &struct {
 		page.StaticPage
 		*OAuthButtons
 		To            string
 		Message       template.HTML
 		Flashes       []template.HTML
 		LoginUsername string
 	}{
 		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 != "" {
 		p.Flashes = append(p.Flashes, template.HTML(earlyError))
 	}
 
 	// Display any error messages
 	flashes, _ := getSessionFlashes(app, w, r, session)
 	for _, flash := range flashes {
 		p.Flashes = append(p.Flashes, template.HTML(flash))
 	}
 	err = pages["login.tmpl"].ExecuteTemplate(w, "base", p)
 	if err != nil {
 		log.Error("Unable to render login: %v", err)
 		return err
 	}
 	return nil
 }
 
 func webLogin(app *App, w http.ResponseWriter, r *http.Request) error {
 	err := login(app, w, r)
 	if err != nil {
 		username := r.FormValue("alias")
 		// Login request was unsuccessful; save the error in the session and redirect them
 		if err, ok := err.(impart.HTTPError); ok {
 			session, _ := app.sessionStore.Get(r, cookieName)
 			if session != nil {
 				session.AddFlash(err.Message)
 				session.Save(r, w)
 			}
 
 			if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 {
 				// Retain fixed username recommendation for the login form
 				username = m[1]
 			}
 		}
 
 		// Pass along certain information
 		saveTempInfo(app, "login-user", username, r, w)
 
 		// Retain post-login URL if one was given
 		redirectTo := "/login"
 		postLoginRedirect := r.FormValue("to")
 		if postLoginRedirect != "" {
 			redirectTo += "?to=" + postLoginRedirect
 		}
 
 		log.Error("Unable to login: %v", err)
 		return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo}
 	}
 
 	return nil
 }
 
 var loginAttemptUsers = sync.Map{}
 
 func login(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	oneTimeToken := r.FormValue("with")
 	verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "")
 
 	redirectTo := r.FormValue("to")
 	if redirectTo == "" {
 		if app.cfg.App.SingleUser {
 			redirectTo = "/me/new"
 		} else {
 			redirectTo = "/"
 		}
 	}
 
 	var u *User
 	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.")
 		userID := app.db.GetUserID(oneTimeToken)
 		if userID == -1 {
 			log.Error("Login: Got user -1 from token")
 			err := ErrBadAccessToken
 			err.Message = "Expired or invalid login code."
 			return err
 		}
 		log.Info("Login: Found user %d.", userID)
 
 		u, err = app.db.GetUserByID(userID)
 		if err != nil {
 			log.Error("Unable to fetch user on one-time token login: %v", err)
 			return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."}
 		}
 		log.Info("Login: Got user via token")
 	} else {
 		// Get params
 		if reqJSON {
 			decoder := json.NewDecoder(r.Body)
 			err := decoder.Decode(&signin)
 			if err != nil {
 				log.Error("Couldn't parse signin JSON request: %v\n", err)
 				return ErrBadJSON
 			}
 		} else {
 			err := r.ParseForm()
 			if err != nil {
 				log.Error("Couldn't parse signin form request: %v\n", err)
 				return ErrBadFormData
 			}
 
 			err = app.formDecoder.Decode(&signin, r.PostForm)
 			if err != nil {
 				log.Error("Couldn't decode signin form request: %v\n", err)
 				return ErrBadFormData
 			}
 		}
 
 		log.Info("Login: Attempting login for '%s'", signin.Alias)
 
 		// Validate required params (all)
 		if signin.Alias == "" {
 			msg := "Parameter `alias` required."
 			if signin.Web {
 				msg = "A username is required."
 			}
 			return impart.HTTPError{http.StatusBadRequest, msg}
 		}
 		if !signin.EmailLogin && signin.Pass == "" {
 			msg := "Parameter `pass` required."
 			if signin.Web {
 				msg = "A password is required."
 			}
 			return impart.HTTPError{http.StatusBadRequest, msg}
 		}
 
 		// Prevent excessive login attempts on the same account
 		// Skip this check in dev environment
 		if !app.cfg.Server.Dev {
 			now := time.Now()
 			attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration))
 			if att {
 				if attemptExpTime, ok := attemptExp.(time.Time); ok {
 					if attemptExpTime.After(now) {
 						// This user attempted previously, and the period hasn't expired yet
 						return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."}
 					} else {
 						// This user attempted previously, but the time expired; free up space
 						loginAttemptUsers.Delete(signin.Alias)
 					}
 				} else {
 					log.Error("Unable to cast expiration to time")
 				}
 			}
 		}
 
 		// Retrieve password
 		u, err = app.db.GetUserForAuth(signin.Alias)
 		if err != nil {
 			log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err)
 			if strings.IndexAny(signin.Alias, "@") > 0 {
 				log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message)
 				return ErrUserNotFoundEmail
 			}
 			return err
 		}
 		// Authenticate
 		if u.Email.String == "" {
 			// User has no email set, so check if they haven't added a password, either,
 			// so we can return a more helpful error message.
 			if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
 				log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
 				return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
 			}
 		}
 		if len(u.HashedPass) == 0 {
 			return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
 		}
 		if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
 			return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
 		}
 	}
 
 	if reqJSON && !signin.Web {
 		var token string
 		if r.Header.Get("User-Agent") == "" {
 			// Get last created token when User-Agent is empty
 			token = app.db.FetchLastAccessToken(u.ID)
 			if token == "" {
 				token, err = app.db.GetAccessToken(u.ID)
 			}
 		} else {
 			token, err = app.db.GetAccessToken(u.ID)
 		}
 		if err != nil {
 			log.Error("Login: Unable to create access token: %v", err)
 			return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
 		}
 		resUser := getVerboseAuthUser(app, token, u, verbose)
 		return impart.WriteSuccess(w, resUser, http.StatusOK)
 	}
 
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		// The cookie should still save, even if there's an error.
 		log.Error("Login: Session: %v; ignoring", err)
 	}
 
 	// Remove unwanted data
 	session.Values[cookieUserVal] = u.Cookie()
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Login: Couldn't save session: %v", err)
 		// TODO: return error
 	}
 
 	// Send success
 	if reqJSON {
 		return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK)
 	}
 	log.Info("Login: Redirecting to %s", redirectTo)
 	w.Header().Set("Location", redirectTo)
 	w.WriteHeader(http.StatusFound)
 	return nil
 }
 
 func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser {
 	resUser := &AuthUser{
 		AccessToken: token,
 		User:        u,
 	}
 
 	// Fetch verbose user data if requested
 	if verbose {
 		posts, err := app.db.GetUserPosts(u)
 		if err != nil {
 			log.Error("Login: Unable to get user posts: %v", err)
 		}
 		colls, err := app.db.GetCollections(u, app.cfg.App.Host)
 		if err != nil {
 			log.Error("Login: Unable to get user collections: %v", err)
 		}
 		passIsSet, err := app.db.IsUserPassSet(u.ID)
 		if err != nil {
 			// TODO: correct error meesage
 			log.Error("Login: Unable to get user collections: %v", err)
 		}
 
 		resUser.Posts = posts
 		resUser.Collections = colls
 		resUser.User.HasPass = passIsSet
 	}
 	return resUser
 }
 
 func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	// Fetch extra user data
 	p := NewUserPage(app, r, u, "Export", nil)
 
 	showUserPage(w, "export", p)
 	return nil
 }
 
 func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
 	var filename string
 	var u = &User{}
 	reqJSON := IsJSON(r)
 	if reqJSON {
 		// Use given Authorization header
 		accessToken := r.Header.Get("Authorization")
 		if accessToken == "" {
 			return nil, filename, ErrNoAccessToken
 		}
 
 		userID := app.db.GetUserID(accessToken)
 		if userID == -1 {
 			return nil, filename, ErrBadAccessToken
 		}
 
 		var err error
 		u, err = app.db.GetUserByID(userID)
 		if err != nil {
 			return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."}
 		}
 	} else {
 		// Use user cookie
 		session, err := app.sessionStore.Get(r, cookieName)
 		if err != nil {
 			// The cookie should still save, even if there's an error.
 			log.Error("Session: %v; ignoring", err)
 		}
 
 		val := session.Values[cookieUserVal]
 		var ok bool
 		if u, ok = val.(*User); !ok {
 			return nil, filename, ErrNotLoggedIn
 		}
 	}
 
 	filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
 
 	// Fetch data we're exporting
 	var err error
 	var data []byte
 	posts, err := app.db.GetUserPosts(u)
 	if err != nil {
 		return data, filename, err
 	}
 
 	// Export as CSV
 	if strings.HasSuffix(r.URL.Path, ".csv") {
 		data = exportPostsCSV(app.cfg.App.Host, u, posts)
 		return data, filename, err
 	}
 	if strings.HasSuffix(r.URL.Path, ".zip") {
 		data = exportPostsZip(u, posts)
 		return data, filename, err
 	}
 
 	if r.FormValue("pretty") == "1" {
 		data, err = json.MarshalIndent(posts, "", "\t")
 	} else {
 		data, err = json.Marshal(posts)
 	}
 	return data, filename, err
 }
 
 func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
 	var err error
 	filename := ""
 	u := getUserSession(app, r)
 	if u == nil {
 		return nil, filename, ErrNotLoggedIn
 	}
 	filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
 
 	exportUser := compileFullExport(app, u)
 
 	var data []byte
 	if r.FormValue("pretty") == "1" {
 		data, err = json.MarshalIndent(exportUser, "", "\t")
 	} else {
 		data, err = json.Marshal(exportUser)
 	}
 	return data, filename, err
 }
 
 func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	uObj := struct {
 		ID       int64  `json:"id,omitempty"`
 		Username string `json:"username,omitempty"`
 	}{}
 	var err error
 
 	if reqJSON {
 		_, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization"))
 		if err != nil {
 			return err
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return impart.WriteSuccess(w, uObj, http.StatusOK)
 		}
 		uObj.Username = u.Username
 	}
 
 	return impart.WriteSuccess(w, uObj, http.StatusOK)
 }
 
 func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	if !reqJSON {
 		return ErrBadRequestedType
 	}
 
 	var err error
 	p := GetPostsCache(u.ID)
 	if p == nil {
 		userPostsCache.Lock()
 		if userPostsCache.users[u.ID].ready == nil {
 			userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})}
 			userPostsCache.Unlock()
 
 			p, err = app.db.GetUserPosts(u)
 			if err != nil {
 				return err
 			}
 
 			CachePosts(u.ID, p)
 		} else {
 			userPostsCache.Unlock()
 
 			<-userPostsCache.users[u.ID].ready
 			p = GetPostsCache(u.ID)
 		}
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	if !reqJSON {
 		return ErrBadRequestedType
 	}
 
 	p, err := app.db.GetCollections(u, app.cfg.App.Host)
 	if err != nil {
 		return err
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p, err := app.db.GetAnonymousPosts(u)
 	if err != nil {
 		log.Error("unable to fetch anon posts: %v", err)
 	}
 	// nil-out AnonymousPosts slice for easy detection in the template
 	if p != nil && len(*p) == 0 {
 		p = nil
 	}
 
 	f, err := getSessionFlashes(app, w, r, nil)
 	if err != nil {
 		log.Error("unable to fetch flashes: %v", err)
 	}
 
 	c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host)
 	if err != nil {
 		log.Error("unable to fetch collections: %v", err)
 	}
 
 	silenced, err := app.db.IsUserSilenced(u.ID)
 	if err != nil {
 		log.Error("view articles: %v", err)
 	}
 	d := struct {
 		*UserPage
 		AnonymousPosts *[]PublicPost
 		Collections    *[]Collection
 		Silenced       bool
 	}{
 		UserPage:       NewUserPage(app, r, u, u.Username+"'s Posts", f),
 		AnonymousPosts: p,
 		Collections:    c,
 		Silenced:       silenced,
 	}
 	d.UserPage.SetMessaging(u)
 	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
 	w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT")
 	showUserPage(w, "articles", d)
 
 	return nil
 }
 
 func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	c, err := app.db.GetCollections(u, app.cfg.App.Host)
 	if err != nil {
 		log.Error("unable to fetch collections: %v", err)
 		return fmt.Errorf("No collections")
 	}
 
 	f, _ := getSessionFlashes(app, w, r, nil)
 
 	uc, _ := app.db.GetUserCollectionCount(u.ID)
 	// TODO: handle any errors
 
 	silenced, err := app.db.IsUserSilenced(u.ID)
 	if err != nil {
 		log.Error("view collections %v", err)
 		return fmt.Errorf("view collections: %v", err)
 	}
 	d := struct {
 		*UserPage
 		Collections *[]Collection
 
 		UsedCollections, TotalCollections int
 
 		NewBlogsDisabled bool
 		Silenced         bool
 	}{
 		UserPage:         NewUserPage(app, r, u, u.Username+"'s Blogs", f),
 		Collections:      c,
 		UsedCollections:  int(uc),
 		NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
 		Silenced:         silenced,
 	}
 	d.UserPage.SetMessaging(u)
 	showUserPage(w, "collections", d)
 
 	return nil
 }
 
 func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	c, err := app.db.GetCollection(vars["collection"])
 	if err != nil {
 		return err
 	}
 	if c.OwnerID != u.ID {
 		return ErrCollectionNotFound
 	}
 
 	// Add collection properties
 	c.MonetizationPointer = app.db.GetCollectionAttribute(c.ID, "monetization_pointer")
 
 	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)
 	}
 	flashes, _ := getSessionFlashes(app, w, r, nil)
 	obj := struct {
 		*UserPage
 		*Collection
 		Silenced bool
 	}{
 		UserPage:   NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
 		Collection: c,
 		Silenced:   silenced,
 	}
 	obj.UserPage.CollAlias = c.Alias
 
 	showUserPage(w, "collection", obj)
 	return nil
 }
 
 func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 
 	var s userSettings
 	var u *User
 	var sess *sessions.Session
 	var err error
 	if reqJSON {
 		accessToken := r.Header.Get("Authorization")
 		if accessToken == "" {
 			return ErrNoAccessToken
 		}
 
 		u, err = app.db.GetAPIUser(accessToken)
 		if err != nil {
 			return ErrBadAccessToken
 		}
 
 		decoder := json.NewDecoder(r.Body)
 		err := decoder.Decode(&s)
 		if err != nil {
 			log.Error("Couldn't parse settings JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 
 		// Prevent all username updates
 		// TODO: support changing username via JSON API request
 		s.Username = ""
 	} else {
 		u, sess = getUserAndSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 
 		err := r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse settings form request: %v\n", err)
 			return ErrBadFormData
 		}
 
 		err = app.formDecoder.Decode(&s, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode settings form request: %v\n", err)
 			return ErrBadFormData
 		}
 	}
 
 	// Do update
 	postUpdateReturn := r.FormValue("return")
 	redirectTo := "/me/settings"
 	if s.IsLogOut {
 		redirectTo += "?logout=1"
 	} else if postUpdateReturn != "" {
 		redirectTo = postUpdateReturn
 	}
 
 	// Only do updates on values we need
 	if s.Username != "" && s.Username == u.Username {
 		// Username hasn't actually changed; blank it out
 		s.Username = ""
 	}
 	err = app.db.ChangeSettings(app, u, &s)
 	if err != nil {
 		if reqJSON {
 			return err
 		}
 
 		if err, ok := err.(impart.HTTPError); ok {
 			addSessionFlash(app, w, r, err.Message, nil)
 		}
 	} else {
 		// Successful update.
 		if reqJSON {
 			return impart.WriteSuccess(w, u, http.StatusOK)
 		}
 
 		if s.IsLogOut {
 			redirectTo = "/me/logout"
 		} else {
 			sess.Values[cookieUserVal] = u.Cookie()
 			addSessionFlash(app, w, r, "Account updated.", nil)
 		}
 	}
 
 	w.Header().Set("Location", redirectTo)
 	w.WriteHeader(http.StatusFound)
 	return nil
 }
 
 func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error {
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" {
 		return ErrNoAccessToken
 	}
 
 	curPass := r.FormValue("current")
 	newPass := r.FormValue("new")
 	// Ensure a new password is given (always required)
 	if newPass == "" {
 		return impart.HTTPError{http.StatusBadRequest, "Provide a new password."}
 	}
 
 	userID, sudo := app.db.GetUserIDPrivilege(accessToken)
 	if userID == -1 {
 		return ErrBadAccessToken
 	}
 
 	// Ensure a current password is given if the access token doesn't have sudo
 	// privileges.
 	if !sudo && curPass == "" {
 		return impart.HTTPError{http.StatusBadRequest, "Provide current password."}
 	}
 
 	// Hash the new password
 	hashedPass, err := auth.HashPass([]byte(newPass))
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
 	}
 
 	// Do update
 	err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass)
 	if err != nil {
 		return err
 	}
 
 	return impart.WriteSuccess(w, struct{}{}, http.StatusOK)
 }
 
 func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	var c *Collection
 	var err error
 	vars := mux.Vars(r)
 	alias := vars["collection"]
 	if alias != "" {
 		c, err = app.db.GetCollection(alias)
 		if err != nil {
 			return err
 		}
 		if c.OwnerID != u.ID {
 			return ErrCollectionNotFound
 		}
 	}
 
 	topPosts, err := app.db.GetTopPosts(u, alias)
 	if err != nil {
 		log.Error("Unable to get top posts: %v", err)
 		return err
 	}
 
 	flashes, _ := getSessionFlashes(app, w, r, nil)
 	titleStats := ""
 	if c != nil {
 		titleStats = c.DisplayTitle() + " "
 	}
 
 	silenced, err := app.db.IsUserSilenced(u.ID)
 	if err != nil {
 		log.Error("view stats: %v", err)
 		return err
 	}
 	obj := struct {
 		*UserPage
 		VisitsBlog  string
 		Collection  *Collection
 		TopPosts    *[]PublicPost
 		APFollowers int
 		Silenced    bool
 	}{
 		UserPage:   NewUserPage(app, r, u, titleStats+"Stats", flashes),
 		VisitsBlog: alias,
 		Collection: c,
 		TopPosts:   topPosts,
 		Silenced:   silenced,
 	}
 	obj.UserPage.CollAlias = c.Alias
 	if app.cfg.App.Federation {
 		folls, err := app.db.GetAPFollowers(c)
 		if err != nil {
 			return err
 		}
 		obj.APFollowers = len(*folls)
 	}
 
 	showUserPage(w, "stats", obj)
 	return nil
 }
 
 func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	fullUser, err := app.db.GetUserByID(u.ID)
 	if err != nil {
 		log.Error("Unable to get user for settings: %s", err)
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
 	}
 
 	passIsSet, err := app.db.IsUserPassSet(u.ID)
 	if err != nil {
 		log.Error("Unable to get isUserPassSet for settings: %s", err)
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
 	}
 
 	flashes, _ := getSessionFlashes(app, w, r, nil)
 
 	enableOauthSlack := app.Config().SlackOauth.ClientID != ""
 	enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
 	enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
 	enableOauthGeneric := app.Config().GenericOauth.ClientID != ""
 	enableOauthGitea := app.Config().GiteaOauth.ClientID != ""
 
 	oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
 	if err != nil {
 		log.Error("Unable to get oauth accounts for settings: %s", err)
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
 	}
 	for idx, oauthAccount := range oauthAccounts {
 		switch oauthAccount.Provider {
 		case "slack":
 			enableOauthSlack = false
 		case "write.as":
 			enableOauthWriteAs = false
 		case "gitlab":
 			enableOauthGitLab = false
 		case "generic":
 			oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName
 			oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect
 			enableOauthGeneric = false
 		case "gitea":
 			enableOauthGitea = false
 		}
 	}
 
 	displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0
 
 	obj := struct {
 		*UserPage
 		Email                   string
 		HasPass                 bool
 		IsLogOut                bool
 		Silenced                bool
 		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",
 		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)
 	return nil
 }
 
 func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
 	session, err := app.sessionStore.Get(r, "t")
 	if err != nil {
 		return ErrInternalCookieSession
 	}
 
 	session.Values[key] = val
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err)
 	}
 	return err
 }
 
 func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string {
 	session, err := app.sessionStore.Get(r, "t")
 	if err != nil {
 		return ""
 	}
 
 	// Get the information
 	var s = ""
 	var ok bool
 	if s, ok = session.Values[key].(string); !ok {
 		return ""
 	}
 
 	// Delete cookie
 	session.Options.MaxAge = -1
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Couldn't erase temp data for key %s: %v", key, err)
 	}
 
 	// 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
 }
diff --git a/admin.go b/admin.go
index a0d10eb..11af4f8 100644
--- a/admin.go
+++ b/admin.go
@@ -1,638 +1,638 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"database/sql"
 	"fmt"
 	"net/http"
 	"runtime"
 	"strconv"
 	"strings"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/auth"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/web-core/passgen"
-	"github.com/writeas/writefreely/appstats"
-	"github.com/writeas/writefreely/config"
+	"github.com/writefreely/writefreely/appstats"
+	"github.com/writefreely/writefreely/config"
 )
 
 var (
 	appStartTime = time.Now()
 	sysStatus    systemStatus
 )
 
 const adminUsersPerPage = 30
 
 type systemStatus struct {
 	Uptime       string
 	NumGoroutine int
 
 	// General statistics.
 	MemAllocated string // bytes allocated and still in use
 	MemTotal     string // bytes allocated (even if freed)
 	MemSys       string // bytes obtained from system (sum of XxxSys below)
 	Lookups      uint64 // number of pointer lookups
 	MemMallocs   uint64 // number of mallocs
 	MemFrees     uint64 // number of frees
 
 	// Main allocation heap statistics.
 	HeapAlloc    string // bytes allocated and still in use
 	HeapSys      string // bytes obtained from system
 	HeapIdle     string // bytes in idle spans
 	HeapInuse    string // bytes in non-idle span
 	HeapReleased string // bytes released to the OS
 	HeapObjects  uint64 // total number of allocated objects
 
 	// Low-level fixed-size structure allocator statistics.
 	//	Inuse is bytes used now.
 	//	Sys is bytes obtained from system.
 	StackInuse  string // bootstrap stacks
 	StackSys    string
 	MSpanInuse  string // mspan structures
 	MSpanSys    string
 	MCacheInuse string // mcache structures
 	MCacheSys   string
 	BuckHashSys string // profiling bucket hash table
 	GCSys       string // GC metadata
 	OtherSys    string // other system allocations
 
 	// Garbage collector statistics.
 	NextGC       string // next run in HeapAlloc time (bytes)
 	LastGC       string // last run in absolute time (ns)
 	PauseTotalNs string
 	PauseNs      string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
 	NumGC        uint32
 }
 
 type inspectedCollection struct {
 	CollectionObj
 	Followers int
 	LastPost  string
 }
 
 type instanceContent struct {
 	ID      string
 	Type    string
 	Title   sql.NullString
 	Content string
 	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
 		var loc monday.Locale = monday.LocaleEnUS
 		return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
 	*/
 	return c.Updated.Format("January 2, 2006, 3:04 PM")
 }
 
 func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p := struct {
 		*UserPage
 		*AdminPage
 		Message string
 
 		UsersCount, CollectionsCount, PostsCount int64
 	}{
 		UserPage:  NewUserPage(app, r, u, "Admin", nil),
 		AdminPage: NewAdminPage(app),
 		Message:   r.FormValue("m"),
 	}
 
 	// Get user stats
 	p.UsersCount = app.db.GetAllUsersCount()
 	var err error
 	p.CollectionsCount, err = app.db.GetTotalCollections()
 	if err != nil {
 		return err
 	}
 	p.PostsCount, err = app.db.GetTotalPosts()
 	if err != nil {
 		return err
 	}
 
 	showUserPage(w, "admin", p)
 	return nil
 }
 
 func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	updateAppStats()
 	p := struct {
 		*UserPage
 		*AdminPage
 		SysStatus systemStatus
 		Config    config.AppCfg
 
 		Message, ConfigMessage string
 	}{
 		UserPage:  NewUserPage(app, r, u, "Admin", nil),
 		AdminPage: NewAdminPage(app),
 		SysStatus: sysStatus,
 		Config:    app.cfg.App,
 
 		Message:       r.FormValue("m"),
 		ConfigMessage: r.FormValue("cm"),
 	}
 
 	showUserPage(w, "monitor", p)
 	return nil
 }
 
 func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p := struct {
 		*UserPage
 		*AdminPage
 		Config config.AppCfg
 
 		Message, ConfigMessage string
 	}{
 		UserPage:  NewUserPage(app, r, u, "Admin", nil),
 		AdminPage: NewAdminPage(app),
 		Config:    app.cfg.App,
 
 		Message:       r.FormValue("m"),
 		ConfigMessage: r.FormValue("cm"),
 	}
 
 	showUserPage(w, "app-settings", p)
 	return nil
 }
 
 func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p := struct {
 		*UserPage
 		*AdminPage
 		Config  config.AppCfg
 		Message string
 
 		Users      *[]User
 		CurPage    int
 		TotalUsers int64
 		TotalPages []int
 	}{
 		UserPage:  NewUserPage(app, r, u, "Users", nil),
 		AdminPage: NewAdminPage(app),
 		Config:    app.cfg.App,
 		Message:   r.FormValue("m"),
 	}
 
 	p.TotalUsers = app.db.GetAllUsersCount()
 	ttlPages := p.TotalUsers / adminUsersPerPage
 	p.TotalPages = []int{}
 	for i := 1; i <= int(ttlPages); i++ {
 		p.TotalPages = append(p.TotalPages, i)
 	}
 
 	var err error
 	p.CurPage, err = strconv.Atoi(r.FormValue("p"))
 	if err != nil || p.CurPage < 1 {
 		p.CurPage = 1
 	} else if p.CurPage > int(ttlPages) {
 		p.CurPage = int(ttlPages)
 	}
 
 	p.Users, err = app.db.GetAllUsers(uint(p.CurPage))
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)}
 	}
 
 	showUserPage(w, "users", p)
 	return nil
 }
 
 func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	username := vars["username"]
 	if username == "" {
 		return impart.HTTPError{http.StatusFound, "/admin/users"}
 	}
 
 	p := struct {
 		*UserPage
 		*AdminPage
 		Config  config.AppCfg
 		Message string
 
 		User        *User
 		Colls       []inspectedCollection
 		LastPost    string
 		NewPassword string
 		TotalPosts  int64
 		ClearEmail  string
 	}{
 		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 {
 		if err == ErrUserNotFound {
 			return err
 		}
 		log.Error("Could not get user: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, err.Error()}
 	}
 
 	flashes, _ := getSessionFlashes(app, w, r, nil)
 	for _, flash := range flashes {
 		if strings.HasPrefix(flash, "SUCCESS: ") {
 			p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ")
 			p.ClearEmail = p.User.EmailClear(app.keys)
 		}
 	}
 	p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
 	p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
 	lp, err := app.db.GetUserLastPostTime(p.User.ID)
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)}
 	}
 	if lp != nil {
 		p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
 	}
 
 	colls, err := app.db.GetCollections(p.User, app.cfg.App.Host)
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
 	}
 	for _, c := range *colls {
 		ic := inspectedCollection{
 			CollectionObj: CollectionObj{Collection: c},
 		}
 
 		if app.cfg.App.Federation {
 			folls, err := app.db.GetAPFollowers(&c)
 			if err == nil {
 				// TODO: handle error here (at least log it)
 				ic.Followers = len(*folls)
 			}
 		}
 
 		app.db.GetPostsCount(&ic.CollectionObj, true)
 
 		lp, err := app.db.GetCollectionLastPostTime(c.ID)
 		if err != nil {
 			log.Error("Didn't get last post time for collection %d: %v", c.ID, err)
 		}
 		if lp != nil {
 			ic.LastPost = lp.Format("January 2, 2006, 3:04 PM")
 		}
 
 		p.Colls = append(p.Colls, ic)
 	}
 
 	showUserPage(w, "view-user", p)
 	return nil
 }
 
 func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	username := vars["username"]
 	if username == "" {
 		return impart.HTTPError{http.StatusFound, "/admin/users"}
 	}
 
 	user, err := app.db.GetUserForAuth(username)
 	if err != nil {
 		log.Error("failed to get user: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)}
 	}
 	if user.IsSilenced() {
 		err = app.db.SetUserStatus(user.ID, UserActive)
 	} else {
 		err = app.db.SetUserStatus(user.ID, UserSilenced)
 	}
 	if err != nil {
 		log.Error("toggle user silenced: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
 	}
 	return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
 }
 
 func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	username := vars["username"]
 	if username == "" {
 		return impart.HTTPError{http.StatusFound, "/admin/users"}
 	}
 
 	// Generate new random password since none supplied
 	pass := passgen.NewWordish()
 	hashedPass, err := auth.HashPass([]byte(pass))
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
 	}
 
 	userIDVal := r.FormValue("user")
 	log.Info("ADMIN: Changing user %s password", userIDVal)
 	id, err := strconv.Atoi(userIDVal)
 	if err != nil {
 		return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)}
 	}
 
 	err = app.db.ChangePassphrase(int64(id), true, "", hashedPass)
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
 	}
 	log.Info("ADMIN: Successfully changed.")
 
 	addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil)
 
 	return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)}
 }
 
 func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p := struct {
 		*UserPage
 		*AdminPage
 		Config  config.AppCfg
 		Message string
 
 		Pages []*instanceContent
 	}{
 		UserPage:  NewUserPage(app, r, u, "Pages", nil),
 		AdminPage: NewAdminPage(app),
 		Config:    app.cfg.App,
 		Message:   r.FormValue("m"),
 	}
 
 	var err error
 	p.Pages, err = app.db.GetInstancePages()
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)}
 	}
 
 	// Add in default pages
 	var hasAbout, hasPrivacy bool
 	for i, c := range p.Pages {
 		if hasAbout && hasPrivacy {
 			break
 		}
 		if c.ID == "about" {
 			hasAbout = true
 			if !c.Title.Valid {
 				p.Pages[i].Title = defaultAboutTitle(app.cfg)
 			}
 		} else if c.ID == "privacy" {
 			hasPrivacy = true
 			if !c.Title.Valid {
 				p.Pages[i].Title = defaultPrivacyTitle()
 			}
 		}
 	}
 	if !hasAbout {
 		p.Pages = append(p.Pages, &instanceContent{
 			ID:      "about",
 			Title:   defaultAboutTitle(app.cfg),
 			Content: defaultAboutPage(app.cfg),
 			Updated: defaultPageUpdatedTime,
 		})
 	}
 	if !hasPrivacy {
 		p.Pages = append(p.Pages, &instanceContent{
 			ID:      "privacy",
 			Title:   defaultPrivacyTitle(),
 			Content: defaultPrivacyPolicy(app.cfg),
 			Updated: defaultPageUpdatedTime,
 		})
 	}
 
 	showUserPage(w, "pages", p)
 	return nil
 }
 
 func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	slug := vars["slug"]
 	if slug == "" {
 		return impart.HTTPError{http.StatusFound, "/admin/pages"}
 	}
 
 	p := struct {
 		*UserPage
 		*AdminPage
 		Config  config.AppCfg
 		Message string
 
 		Banner  *instanceContent
 		Content *instanceContent
 	}{
 		AdminPage: NewAdminPage(app),
 		Config:    app.cfg.App,
 		Message:   r.FormValue("m"),
 	}
 
 	var err error
 	// Get pre-defined pages, or select slug
 	if slug == "about" {
 		p.Content, err = getAboutPage(app)
 	} else if slug == "privacy" {
 		p.Content, err = getPrivacyPage(app)
 	} else if slug == "landing" {
 		p.Banner, err = getLandingBanner(app)
 		if err != nil {
 			return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
 		}
 		p.Content, err = getLandingBody(app)
 		p.Content.ID = "landing"
 	} else if slug == "reader" {
 		p.Content, err = getReaderSection(app)
 	} else {
 		p.Content, err = app.db.GetDynamicContent(slug)
 	}
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)}
 	}
 	title := "New page"
 	if p.Content != nil {
 		title = "Edit " + p.Content.ID
 	} else {
 		p.Content = &instanceContent{}
 	}
 	p.UserPage = NewUserPage(app, r, u, title, nil)
 
 	showUserPage(w, "view-page", p)
 	return nil
 }
 
 func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	id := vars["page"]
 
 	// Validate
 	if id != "about" && id != "privacy" && id != "landing" && id != "reader" {
 		return impart.HTTPError{http.StatusNotFound, "No such page."}
 	}
 
 	var err error
 	m := ""
 	if id == "landing" {
 		// Handle special landing page
 		err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section")
 		if err != nil {
 			m = "?m=" + err.Error()
 			return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
 		}
 		err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
 	} else if id == "reader" {
 		// Update sections with titles
 		err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section")
 	} else {
 		// Update page
 		err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
 	}
 	if err != nil {
 		m = "?m=" + err.Error()
 	}
 	return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
 }
 
 func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error {
 	apper.App().cfg.App.SiteName = r.FormValue("site_name")
 	apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
 	apper.App().cfg.App.Landing = r.FormValue("landing")
 	apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
 	mul, err := strconv.Atoi(r.FormValue("min_username_len"))
 	if err == nil {
 		apper.App().cfg.App.MinUsernameLen = mul
 	}
 	mb, err := strconv.Atoi(r.FormValue("max_blogs"))
 	if err == nil {
 		apper.App().cfg.App.MaxBlogs = mb
 	}
 	apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
 	apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
 	apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on"
 	apper.App().cfg.App.Private = r.FormValue("private") == "on"
 	apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
 	if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
 		log.Info("Initializing local timeline...")
 		initLocalTimeline(apper.App())
 	}
 	apper.App().cfg.App.UserInvites = r.FormValue("user_invites")
 	if apper.App().cfg.App.UserInvites == "none" {
 		apper.App().cfg.App.UserInvites = ""
 	}
 	apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")
 
 	m := "?cm=Configuration+saved."
 	err = apper.SaveConfig(apper.App().cfg)
 	if err != nil {
 		m = "?cm=" + err.Error()
 	}
 	return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
 }
 
 func updateAppStats() {
 	sysStatus.Uptime = appstats.TimeSincePro(appStartTime)
 
 	m := new(runtime.MemStats)
 	runtime.ReadMemStats(m)
 	sysStatus.NumGoroutine = runtime.NumGoroutine()
 
 	sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc))
 	sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc))
 	sysStatus.MemSys = appstats.FileSize(int64(m.Sys))
 	sysStatus.Lookups = m.Lookups
 	sysStatus.MemMallocs = m.Mallocs
 	sysStatus.MemFrees = m.Frees
 
 	sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc))
 	sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys))
 	sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle))
 	sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse))
 	sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased))
 	sysStatus.HeapObjects = m.HeapObjects
 
 	sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse))
 	sysStatus.StackSys = appstats.FileSize(int64(m.StackSys))
 	sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse))
 	sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys))
 	sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse))
 	sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys))
 	sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys))
 	sysStatus.GCSys = appstats.FileSize(int64(m.GCSys))
 	sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys))
 
 	sysStatus.NextGC = appstats.FileSize(int64(m.NextGC))
 	sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
 	sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
 	sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
 	sysStatus.NumGC = m.NumGC
 }
 
 func adminResetPassword(app *App, u *User, newPass string) error {
 	hashedPass, err := auth.HashPass([]byte(newPass))
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
 	}
 
 	err = app.db.ChangePassphrase(u.ID, true, "", hashedPass)
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
 	}
 	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
 }
diff --git a/app.go b/app.go
index d34daf5..ed4e096 100644
--- a/app.go
+++ b/app.go
@@ -1,906 +1,906 @@
 /*
  * Copyright © 2018-2021 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/tls"
 	"database/sql"
 	"fmt"
 	"html/template"
 	"io/ioutil"
 	"net/http"
 	"net/url"
 	"os"
 	"os/signal"
 	"path/filepath"
 	"regexp"
 	"strings"
 	"syscall"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/gorilla/schema"
 	"github.com/gorilla/sessions"
 	"github.com/manifoldco/promptui"
 	stripmd "github.com/writeas/go-strip-markdown"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/auth"
 	"github.com/writeas/web-core/converter"
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/author"
-	"github.com/writeas/writefreely/config"
-	"github.com/writeas/writefreely/key"
-	"github.com/writeas/writefreely/migrations"
-	"github.com/writeas/writefreely/page"
+	"github.com/writefreely/writefreely/author"
+	"github.com/writefreely/writefreely/config"
+	"github.com/writefreely/writefreely/key"
+	"github.com/writefreely/writefreely/migrations"
+	"github.com/writefreely/writefreely/page"
 	"golang.org/x/crypto/acme/autocert"
 )
 
 const (
 	staticDir       = "static"
 	assumedTitleLen = 80
 	postsPerPage    = 10
 
 	serverSoftware = "WriteFreely"
 	softwareURL    = "https://writefreely.org"
 )
 
 var (
 	debugging bool
 
 	// Software version can be set from git env using -ldflags
 	softwareVer = "0.12.0"
 
 	// DEPRECATED VARS
 	isSingleUser bool
 )
 
 // App holds data and configuration for an individual WriteFreely instance.
 type App struct {
 	router       *mux.Router
 	shttp        *http.ServeMux
 	db           *datastore
 	cfg          *config.Config
 	cfgFile      string
 	keys         *key.Keychain
 	sessionStore sessions.Store
 	formDecoder  *schema.Decoder
 	updates      *updatesCache
 
 	timeline *localTimeline
 }
 
 // DB returns the App's datastore
 func (app *App) DB() *datastore {
 	return app.db
 }
 
 // Router returns the App's router
 func (app *App) Router() *mux.Router {
 	return app.router
 }
 
 // Config returns the App's current configuration.
 func (app *App) Config() *config.Config {
 	return app.cfg
 }
 
 // SetConfig updates the App's Config to the given value.
 func (app *App) SetConfig(cfg *config.Config) {
 	app.cfg = cfg
 }
 
 // SetKeys updates the App's Keychain to the given value.
 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").
 //
 // App returns the App for the current instance.
 //
 // LoadConfig reads an app configuration into the App, returning any error
 // encountered.
 //
 // SaveConfig persists the current App configuration.
 //
 // LoadKeys reads the App's encryption keys and loads them into its
 // key.Keychain.
 type Apper interface {
 	App() *App
 
 	LoadConfig() error
 	SaveConfig(*config.Config) error
 
 	LoadKeys() error
 
 	ReqLog(r *http.Request, status int, timeSince time.Duration) string
 }
 
 // App returns the App
 func (app *App) App() *App {
 	return app
 }
 
 // LoadConfig loads and parses a config file.
 func (app *App) LoadConfig() error {
 	log.Info("Loading %s configuration...", app.cfgFile)
 	cfg, err := config.Load(app.cfgFile)
 	if err != nil {
 		log.Error("Unable to load configuration: %v", err)
 		os.Exit(1)
 		return err
 	}
 	app.cfg = cfg
 	return nil
 }
 
 // SaveConfig saves the given Config to disk -- namely, to the App's cfgFile.
 func (app *App) SaveConfig(c *config.Config) error {
 	return config.Save(c, app.cfgFile)
 }
 
 // LoadKeys reads all needed keys from disk into the App. In order to use the
 // configured `Server.KeysParentDir`, you must call initKeyPaths(App) before
 // this.
 func (app *App) LoadKeys() error {
 	var err error
 	app.keys = &key.Keychain{}
 
 	if debugging {
 		log.Info("  %s", emailKeyPath)
 	}
 	app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
 	if err != nil {
 		return err
 	}
 
 	if debugging {
 		log.Info("  %s", cookieAuthKeyPath)
 	}
 	app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
 	if err != nil {
 		return err
 	}
 
 	if debugging {
 		log.Info("  %s", cookieKeyPath)
 	}
 	app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
 	if err != nil {
 		return err
 	}
 
 	return nil
 }
 
 func (app *App) ReqLog(r *http.Request, status int, timeSince time.Duration) string {
 	return fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, timeSince, r.UserAgent())
 }
 
 // handleViewHome shows page at root path. It checks the configuration and
 // authentication state to show the correct page.
 func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
 	if app.cfg.App.SingleUser {
 		// Render blog index
 		return handleViewCollection(app, w, r)
 	}
 
 	// Multi-user instance
 	forceLanding := r.FormValue("landing") == "1"
 	if !forceLanding {
 		// Show correct page based on user auth status and configured landing path
 		u := getUserSession(app, r)
 
 		if app.cfg.App.Chorus {
 			// This instance is focused on reading, so show Reader on home route if not
 			// private or a private-instance user is logged in.
 			if !app.cfg.App.Private || u != nil {
 				return viewLocalTimeline(app, w, r)
 			}
 		}
 
 		if u != nil {
 			// User is logged in, so show the Pad
 			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}
 		}
 	}
 
 	return handleViewLanding(app, w, r)
 }
 
 func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
 	forceLanding := r.FormValue("landing") == "1"
 
 	p := struct {
 		page.StaticPage
 		*OAuthButtons
 		Flashes []template.HTML
 		Banner  template.HTML
 		Content template.HTML
 
 		ForcedLanding bool
 	}{
 		StaticPage:    pageForReq(app, r),
 		OAuthButtons:  NewOAuthButtons(app.Config()),
 		ForcedLanding: forceLanding,
 	}
 
 	banner, err := getLandingBanner(app)
 	if err != nil {
 		log.Error("unable to get landing banner: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
 	}
 	p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), "", app.cfg))
 
 	content, err := getLandingBody(app)
 	if err != nil {
 		log.Error("unable to get landing content: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)}
 	}
 	p.Content = template.HTML(applyMarkdown([]byte(content.Content), "", app.cfg))
 
 	// Get error messages
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		// Ignore this
 		log.Error("Unable to get session in handleViewHome; ignoring: %v", err)
 	}
 	flashes, _ := getSessionFlashes(app, w, r, session)
 	for _, flash := range flashes {
 		p.Flashes = append(p.Flashes, template.HTML(flash))
 	}
 
 	// Show landing page
 	return renderPage(w, "landing.tmpl", p)
 }
 
 func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error {
 	p := struct {
 		page.StaticPage
 		ContentTitle string
 		Content      template.HTML
 		PlainContent string
 		Updated      string
 
 		AboutStats *InstanceStats
 	}{
 		StaticPage: pageForReq(app, r),
 	}
 	if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
 		var c *instanceContent
 		var err error
 
 		if r.URL.Path == "/about" {
 			c, err = getAboutPage(app)
 
 			// Fetch stats
 			p.AboutStats = &InstanceStats{}
 			p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
 			p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
 		} else {
 			c, err = getPrivacyPage(app)
 		}
 
 		if err != nil {
 			return err
 		}
 		p.ContentTitle = c.Title.String
 		p.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
 		p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
 		if !c.Updated.IsZero() {
 			p.Updated = c.Updated.Format("January 2, 2006")
 		}
 	}
 
 	// Serve templated page
 	err := t.ExecuteTemplate(w, "base", p)
 	if err != nil {
 		log.Error("Unable to render page: %v", err)
 	}
 	return nil
 }
 
 func pageForReq(app *App, r *http.Request) page.StaticPage {
 	p := page.StaticPage{
 		AppCfg:  app.cfg.App,
 		Path:    r.URL.Path,
 		Version: "v" + softwareVer,
 	}
 
 	// Add user information, if given
 	var u *User
 	accessToken := r.FormValue("t")
 	if accessToken != "" {
 		userID := app.db.GetUserID(accessToken)
 		if userID != -1 {
 			var err error
 			u, err = app.db.GetUserByID(userID)
 			if err == nil {
 				p.Username = u.Username
 			}
 		}
 	} else {
 		u = getUserSession(app, r)
 		if u != nil {
 			p.Username = u.Username
 			p.IsAdmin = u != nil && u.IsAdmin()
 			p.CanInvite = canUserInvite(app.cfg, p.IsAdmin)
 		}
 	}
 	p.CanViewReader = !app.cfg.App.Private || u != nil
 
 	return p
 }
 
 var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$")
 
 // Initialize loads the app configuration and initializes templates, keys,
 // session, route handlers, and the database connection.
 func Initialize(apper Apper, debug bool) (*App, error) {
 	debugging = debug
 
 	apper.LoadConfig()
 
 	// Load templates
 	err := InitTemplates(apper.App().Config())
 	if err != nil {
 		return nil, fmt.Errorf("load templates: %s", err)
 	}
 
 	// Load keys and set up session
 	initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations
 	err = InitKeys(apper)
 	if err != nil {
 		return nil, fmt.Errorf("init keys: %s", err)
 	}
 	apper.App().InitUpdates()
 
 	apper.App().InitSession()
 
 	apper.App().InitDecoder()
 
 	err = ConnectToDatabase(apper.App())
 	if err != nil {
 		return nil, fmt.Errorf("connect to DB: %s", err)
 	}
 
 	initActivityPub(apper.App())
 
 	// Handle local timeline, if enabled
 	if apper.App().cfg.App.LocalTimeline {
 		log.Info("Initializing local timeline...")
 		initLocalTimeline(apper.App())
 	}
 
 	return apper.App(), nil
 }
 
 func Serve(app *App, r *mux.Router) {
 	log.Info("Going to serve...")
 
 	isSingleUser = app.cfg.App.SingleUser
 	app.cfg.Server.Dev = debugging
 
 	// Handle shutdown
 	c := make(chan os.Signal, 2)
 	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
 	go func() {
 		<-c
 		log.Info("Shutting down...")
 		shutdown(app)
 		log.Info("Done.")
 		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 == "" {
 		bindAddress = "localhost"
 	}
 	var err error
 	if app.cfg.IsSecureStandalone() {
 		if app.cfg.Server.Autocert {
 			m := &autocert.Manager{
 				Prompt: autocert.AcceptTOS,
 				Cache:  autocert.DirCache(app.cfg.Server.TLSCertPath),
 			}
 			host, err := url.Parse(app.cfg.App.Host)
 			if err != nil {
 				log.Error("[WARNING] Unable to parse configured host! %s", err)
 				log.Error(`[WARNING] ALL hosts are allowed, which can open you to an attack where
 clients connect to a server by IP address and pretend to be asking for an
 incorrect host name, and cause you to reach the CA's rate limit for certificate
 requests. We recommend supplying a valid host name.`)
 				log.Info("Using autocert on ANY host")
 			} else {
 				log.Info("Using autocert on host %s", host.Host)
 				m.HostPolicy = autocert.HostWhitelist(host.Host)
 			}
 			s := &http.Server{
 				Addr:    ":https",
 				Handler: r,
 				TLSConfig: &tls.Config{
 					GetCertificate: m.GetCertificate,
 				},
 			}
 			s.SetKeepAlivesEnabled(false)
 
 			go func() {
 				log.Info("Serving redirects on http://%s:80", bindAddress)
 				err = http.ListenAndServe(":80", m.HTTPHandler(nil))
 				log.Error("Unable to start redirect server: %v", err)
 			}()
 
 			log.Info("Serving on https://%s:443", bindAddress)
 			log.Info("---")
 			err = s.ListenAndServeTLS("", "")
 		} else {
 			go func() {
 				log.Info("Serving redirects on http://%s:80", bindAddress)
 				err = http.ListenAndServe(fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 					http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently)
 				}))
 				log.Error("Unable to start redirect server: %v", err)
 			}()
 
 			log.Info("Serving on https://%s:443", bindAddress)
 			log.Info("Using manual certificates")
 			log.Info("---")
 			err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
 		}
 	} else {
 		log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
 		log.Info("---")
 		err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
 	}
 	if err != nil {
 		log.Error("Unable to start: %v", err)
 		os.Exit(1)
 	}
 }
 
 func (app *App) InitDecoder() {
 	// TODO: do this at the package level, instead of the App level
 	// Initialize modules
 	app.formDecoder = schema.NewDecoder()
 	app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString)
 	app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool)
 	app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString)
 	app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool)
 	app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64)
 	app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64)
 }
 
 // ConnectToDatabase validates and connects to the configured database, then
 // tests the connection.
 func ConnectToDatabase(app *App) error {
 	// Check database configuration
 	if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
 		return fmt.Errorf("Database user or password not set.")
 	}
 	if app.cfg.Database.Host == "" {
 		app.cfg.Database.Host = "localhost"
 	}
 	if app.cfg.Database.Database == "" {
 		app.cfg.Database.Database = "writefreely"
 	}
 
 	// TODO: check err
 	connectToDatabase(app)
 
 	// Test database connection
 	err := app.db.Ping()
 	if err != nil {
 		return fmt.Errorf("Database ping failed: %s", err)
 	}
 
 	return nil
 }
 
 // FormatVersion constructs the version string for the application
 func FormatVersion() string {
 	return serverSoftware + " " + softwareVer
 }
 
 // OutputVersion prints out the version of the application.
 func OutputVersion() {
 	fmt.Println(FormatVersion())
 }
 
 // NewApp creates a new app instance.
 func NewApp(cfgFile string) *App {
 	return &App{
 		cfgFile: cfgFile,
 	}
 }
 
 // CreateConfig creates a default configuration and saves it to the app's cfgFile.
 func CreateConfig(app *App) error {
 	log.Info("Creating configuration...")
 	c := config.New()
 	log.Info("Saving configuration %s...", app.cfgFile)
 	err := config.Save(c, app.cfgFile)
 	if err != nil {
 		return fmt.Errorf("Unable to save configuration: %v", err)
 	}
 	return nil
 }
 
 // DoConfig runs the interactive configuration process.
 func DoConfig(app *App, configSections string) {
 	if configSections == "" {
 		configSections = "server db app"
 	}
 	// let's check there aren't any garbage in the list
 	configSectionsArray := strings.Split(configSections, " ")
 	for _, element := range configSectionsArray {
 		if element != "server" && element != "db" && element != "app" {
 			log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"")
 			os.Exit(1)
 		}
 	}
 	d, err := config.Configure(app.cfgFile, configSections)
 	if err != nil {
 		log.Error("Unable to configure: %v", err)
 		os.Exit(1)
 	}
 	app.cfg = d.Config
 	connectToDatabase(app)
 	defer shutdown(app)
 
 	if !app.db.DatabaseInitialized() {
 		err = adminInitDatabase(app)
 		if err != nil {
 			log.Error(err.Error())
 			os.Exit(1)
 		}
 	} else {
 		log.Info("Database already initialized.")
 	}
 
 	if d.User != nil {
 		u := &User{
 			Username:   d.User.Username,
 			HashedPass: d.User.HashedPass,
 			Created:    time.Now().Truncate(time.Second).UTC(),
 		}
 
 		// Create blog
 		log.Info("Creating user %s...\n", u.Username)
 		err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName)
 		if err != nil {
 			log.Error("Unable to create user: %s", err)
 			os.Exit(1)
 		}
 		log.Info("Done!")
 	}
 	os.Exit(0)
 }
 
 // GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir.
 func GenerateKeyFiles(app *App) error {
 	// Read keys path from config
 	app.LoadConfig()
 
 	// Create keys dir if it doesn't exist yet
 	fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir)
 	if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) {
 		err = os.Mkdir(fullKeysDir, 0700)
 		if err != nil {
 			return err
 		}
 	}
 
 	// Generate keys
 	initKeyPaths(app)
 	// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
 	var keyErrs error
 	err := generateKey(emailKeyPath)
 	if err != nil {
 		keyErrs = err
 	}
 	err = generateKey(cookieAuthKeyPath)
 	if err != nil {
 		keyErrs = err
 	}
 	err = generateKey(cookieKeyPath)
 	if err != nil {
 		keyErrs = err
 	}
 
 	return keyErrs
 }
 
 // CreateSchema creates all database tables needed for the application.
 func CreateSchema(apper Apper) error {
 	apper.LoadConfig()
 	connectToDatabase(apper.App())
 	defer shutdown(apper.App())
 	err := adminInitDatabase(apper.App())
 	if err != nil {
 		return err
 	}
 	return nil
 }
 
 // Migrate runs all necessary database migrations.
 func Migrate(apper Apper) error {
 	apper.LoadConfig()
 	connectToDatabase(apper.App())
 	defer shutdown(apper.App())
 
 	err := migrations.Migrate(migrations.NewDatastore(apper.App().db.DB, apper.App().db.driverName))
 	if err != nil {
 		return fmt.Errorf("migrate: %s", err)
 	}
 	return nil
 }
 
 // ResetPassword runs the interactive password reset process.
 func ResetPassword(apper Apper, username string) error {
 	// Connect to the database
 	apper.LoadConfig()
 	connectToDatabase(apper.App())
 	defer shutdown(apper.App())
 
 	// Fetch user
 	u, err := apper.App().db.GetUserForAuth(username)
 	if err != nil {
 		log.Error("Get user: %s", err)
 		os.Exit(1)
 	}
 
 	// Prompt for new password
 	prompt := promptui.Prompt{
 		Templates: &promptui.PromptTemplates{
 			Success: "{{ . | bold | faint }}: ",
 		},
 		Label: "New password",
 		Mask:  '*',
 	}
 	newPass, err := prompt.Run()
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 
 	// Do the update
 	log.Info("Updating...")
 	err = adminResetPassword(apper.App(), u, newPass)
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 	log.Info("Success.")
 	return nil
 }
 
 // DoDeleteAccount runs the confirmation and account delete process.
 func DoDeleteAccount(apper Apper, username string) error {
 	// Connect to the database
 	apper.LoadConfig()
 	connectToDatabase(apper.App())
 	defer shutdown(apper.App())
 
 	// check user exists
 	u, err := apper.App().db.GetUserForAuth(username)
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 	userID := u.ID
 
 	// do not delete the admin account
 	// TODO: check for other admins and skip?
 	if u.IsAdmin() {
 		log.Error("Can not delete admin account")
 		os.Exit(1)
 	}
 
 	// confirm deletion, w/ w/out posts
 	prompt := promptui.Prompt{
 		Templates: &promptui.PromptTemplates{
 			Success: "{{ . | bold | faint }}: ",
 		},
 		Label:     fmt.Sprintf("Really delete user : %s", username),
 		IsConfirm: true,
 	}
 	_, err = prompt.Run()
 	if err != nil {
 		log.Info("Aborted...")
 		os.Exit(0)
 	}
 
 	log.Info("Deleting...")
 	err = apper.App().db.DeleteAccount(userID)
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 	log.Info("Success.")
 	return nil
 }
 
 func connectToDatabase(app *App) {
 	log.Info("Connecting to %s database...", app.cfg.Database.Type)
 
 	var db *sql.DB
 	var err error
 	if app.cfg.Database.Type == driverMySQL {
 		db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS))
 		db.SetMaxOpenConns(50)
 	} else if app.cfg.Database.Type == driverSQLite {
 		if !SQLiteEnabled {
 			log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type)
 			os.Exit(1)
 		}
 		if app.cfg.Database.FileName == "" {
 			log.Error("SQLite database filename value in config.ini is empty.")
 			os.Exit(1)
 		}
 		db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared")
 		db.SetMaxOpenConns(1)
 	} else {
 		log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type)
 		os.Exit(1)
 	}
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 	app.db = &datastore{db, app.cfg.Database.Type}
 }
 
 func shutdown(app *App) {
 	log.Info("Closing database connection...")
 	app.db.Close()
 }
 
 // CreateUser creates a new admin or normal user from the given credentials.
 func CreateUser(apper Apper, username, password string, isAdmin bool) error {
 	// Create an admin user with --create-admin
 	apper.LoadConfig()
 	connectToDatabase(apper.App())
 	defer shutdown(apper.App())
 
 	// Ensure an admin / first user doesn't already exist
 	firstUser, _ := apper.App().db.GetUserByID(1)
 	if isAdmin {
 		// Abort if trying to create admin user, but one already exists
 		if firstUser != nil {
 			return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
 		}
 	} else {
 		// Abort if trying to create regular user, but no admin exists yet
 		if firstUser == nil {
 			return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
 		}
 	}
 
 	// Create the user
 	// Normalize and validate username
 	desiredUsername := username
 	username = getSlug(username, "")
 
 	usernameDesc := username
 	if username != desiredUsername {
 		usernameDesc += " (originally: " + desiredUsername + ")"
 	}
 
 	if !author.IsValidUsername(apper.App().cfg, username) {
 		return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen)
 	}
 
 	// Hash the password
 	hashedPass, err := auth.HashPass([]byte(password))
 	if err != nil {
 		return fmt.Errorf("Unable to hash password: %v", err)
 	}
 
 	u := &User{
 		Username:   username,
 		HashedPass: hashedPass,
 		Created:    time.Now().Truncate(time.Second).UTC(),
 	}
 
 	userType := "user"
 	if isAdmin {
 		userType = "admin"
 	}
 	log.Info("Creating %s %s...", userType, usernameDesc)
 	err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername)
 	if err != nil {
 		return fmt.Errorf("Unable to create user: %s", err)
 	}
 	log.Info("Done!")
 	return nil
 }
 
 func adminInitDatabase(app *App) error {
 	schemaFileName := "schema.sql"
 	if app.cfg.Database.Type == driverSQLite {
 		schemaFileName = "sqlite.sql"
 	}
 
 	schema, err := Asset(schemaFileName)
 	if err != nil {
 		return fmt.Errorf("Unable to load schema file: %v", err)
 	}
 
 	tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
 
 	queries := strings.Split(string(schema), ";\n")
 	for _, q := range queries {
 		if strings.TrimSpace(q) == "" {
 			continue
 		}
 		parts := tblReg.FindStringSubmatch(q)
 		if len(parts) >= 3 {
 			log.Info("Creating table %s...", parts[2])
 		} else {
 			log.Info("Creating table ??? (Weird query) No match in: %v", parts)
 		}
 		_, err = app.db.Exec(q)
 		if err != nil {
 			log.Error("%s", err)
 		} else {
 			log.Info("Created.")
 		}
 	}
 
 	// Set up migrations table
 	log.Info("Initializing appmigrations table...")
 	err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
 	if err != nil {
 		return fmt.Errorf("Unable to set initial migrations: %v", err)
 	}
 
 	log.Info("Running migrations...")
 	err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName))
 	if err != nil {
 		return fmt.Errorf("migrate: %s", err)
 	}
 
 	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 + ")"
 }
diff --git a/author/author.go b/author/author.go
index 0114905..7431ac5 100644
--- a/author/author.go
+++ b/author/author.go
@@ -1,128 +1,128 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 author
 
 import (
-	"github.com/writeas/writefreely/config"
+	"github.com/writefreely/writefreely/config"
 	"os"
 	"path/filepath"
 	"regexp"
 )
 
 // Regex pattern for valid usernames
 var validUsernameReg = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9-]*$")
 
 // List of reserved usernames
 var reservedUsernames = map[string]bool{
 	"a":                true,
 	"about":            true,
 	"add":              true,
 	"admin":            true,
 	"administrator":    true,
 	"adminzone":        true,
 	"api":              true,
 	"article":          true,
 	"articles":         true,
 	"auth":             true,
 	"authenticate":     true,
 	"browse":           true,
 	"c":                true,
 	"categories":       true,
 	"category":         true,
 	"changes":          true,
 	"community":        true,
 	"create":           true,
 	"css":              true,
 	"data":             true,
 	"dev":              true,
 	"developers":       true,
 	"draft":            true,
 	"drafts":           true,
 	"edit":             true,
 	"edits":            true,
 	"faq":              true,
 	"feed":             true,
 	"feedback":         true,
 	"guide":            true,
 	"guides":           true,
 	"help":             true,
 	"index":            true,
 	"invite":           true,
 	"js":               true,
 	"login":            true,
 	"logout":           true,
 	"me":               true,
 	"media":            true,
 	"meta":             true,
 	"metadata":         true,
 	"new":              true,
 	"news":             true,
 	"oauth":            true,
 	"post":             true,
 	"posts":            true,
 	"privacy":          true,
 	"publication":      true,
 	"publications":     true,
 	"publish":          true,
 	"random":           true,
 	"read":             true,
 	"reader":           true,
 	"register":         true,
 	"remove":           true,
 	"signin":           true,
 	"signout":          true,
 	"signup":           true,
 	"start":            true,
 	"status":           true,
 	"summary":          true,
 	"support":          true,
 	"tag":              true,
 	"tags":             true,
 	"team":             true,
 	"template":         true,
 	"templates":        true,
 	"terms":            true,
 	"terms-of-service": true,
 	"termsofservice":   true,
 	"theme":            true,
 	"themes":           true,
 	"tips":             true,
 	"tos":              true,
 	"update":           true,
 	"updates":          true,
 	"user":             true,
 	"users":            true,
 	"yourname":         true,
 }
 
 // IsValidUsername returns true if a given username is neither reserved nor
 // of the correct format.
 func IsValidUsername(cfg *config.Config, username string) bool {
 	// Username has to be above a character limit
 	if len(username) < cfg.App.MinUsernameLen {
 		return false
 	}
 	// Username is invalid if page with the same name exists. So traverse
 	// available pages, adding them to reservedUsernames map that'll be checked
 	// later.
 	filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
 		reservedUsernames[i.Name()] = true
 		return nil
 	})
 
 	// Username is invalid if it is reserved!
 	if _, reserved := reservedUsernames[username]; reserved {
 		return false
 	}
 
 	// TODO: use correct regexp function here
 	return len(validUsernameReg.FindStringSubmatch(username)) > 0
 }
diff --git a/cmd/writefreely/config.go b/cmd/writefreely/config.go
index c5ff455..32e3801 100644
--- a/cmd/writefreely/config.go
+++ b/cmd/writefreely/config.go
@@ -1,61 +1,60 @@
 /*
- * Copyright © 2020 A Bunch Tell LLC.
+ * Copyright © 2020-2021 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"
+	"github.com/writefreely/writefreely"
 )
 
 var (
 	cmdConfig cli.Command = cli.Command{
 		Name:  "config",
 		Usage: "config management tools",
 		Subcommands: []*cli.Command{
 			&cmdConfigGenerate,
 			&cmdConfigInteractive,
 		},
 	}
 
 	cmdConfigGenerate cli.Command = cli.Command{
 		Name:    "generate",
 		Aliases: []string{"gen"},
 		Usage:   "Generate a basic configuration",
 		Action:  genConfigAction,
 	}
 
 	cmdConfigInteractive cli.Command = cli.Command{
 		Name:   "start",
 		Usage:  "Interactive configuration process",
 		Action: interactiveConfigAction,
 		Flags: []cli.Flag{
 			&cli.StringFlag{
 				Name:  "sections",
 				Value: "server db app",
 				Usage: "Which sections of the configuration to go through\n" +
 					"valid values of sections flag are any combination of 'server', 'db' and 'app' \n" +
 					"example: writefreely config start --sections \"db app\"",
 			},
 		},
 	}
 )
 
 func genConfigAction(c *cli.Context) error {
 	app := writefreely.NewApp(c.String("c"))
 	return writefreely.CreateConfig(app)
 }
 
 func interactiveConfigAction(c *cli.Context) error {
 	app := writefreely.NewApp(c.String("c"))
 	writefreely.DoConfig(app, c.String("sections"))
 	return nil
 }
diff --git a/cmd/writefreely/db.go b/cmd/writefreely/db.go
index badc805..ccae418 100644
--- a/cmd/writefreely/db.go
+++ b/cmd/writefreely/db.go
@@ -1,50 +1,49 @@
 /*
- * Copyright © 2020 A Bunch Tell LLC.
+ * Copyright © 2020-2021 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"
+	"github.com/writefreely/writefreely"
 )
 
 var (
 	cmdDB cli.Command = cli.Command{
 		Name:  "db",
 		Usage: "db management tools",
 		Subcommands: []*cli.Command{
 			&cmdDBInit,
 			&cmdDBMigrate,
 		},
 	}
 
 	cmdDBInit cli.Command = cli.Command{
 		Name:   "init",
 		Usage:  "Initialize Database",
 		Action: initDBAction,
 	}
 
 	cmdDBMigrate cli.Command = cli.Command{
 		Name:   "migrate",
 		Usage:  "Migrate Database",
 		Action: migrateDBAction,
 	}
 )
 
 func initDBAction(c *cli.Context) error {
 	app := writefreely.NewApp(c.String("c"))
 	return writefreely.CreateSchema(app)
 }
 
 func migrateDBAction(c *cli.Context) error {
 	app := writefreely.NewApp(c.String("c"))
 	return writefreely.Migrate(app)
 }
diff --git a/cmd/writefreely/keys.go b/cmd/writefreely/keys.go
index 9028f51..680cd4d 100644
--- a/cmd/writefreely/keys.go
+++ b/cmd/writefreely/keys.go
@@ -1,39 +1,38 @@
 /*
- * Copyright © 2020 A Bunch Tell LLC.
+ * Copyright © 2020-2021 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"
+	"github.com/writefreely/writefreely"
 )
 
 var (
 	cmdKeys cli.Command = cli.Command{
 		Name:  "keys",
 		Usage: "key management tools",
 		Subcommands: []*cli.Command{
 			&cmdGenerateKeys,
 		},
 	}
 
 	cmdGenerateKeys cli.Command = cli.Command{
 		Name:    "generate",
 		Aliases: []string{"gen"},
 		Usage:   "Generate encryption and authentication keys",
 		Action:  genKeysAction,
 	}
 )
 
 func genKeysAction(c *cli.Context) error {
 	app := writefreely.NewApp(c.String("c"))
 	return writefreely.GenerateKeyFiles(app)
 }
diff --git a/cmd/writefreely/main.go b/cmd/writefreely/main.go
index 45dfb80..992d611 100644
--- a/cmd/writefreely/main.go
+++ b/cmd/writefreely/main.go
@@ -1,184 +1,183 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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"
 	"os"
 	"strings"
 
-	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely"
-
 	"github.com/gorilla/mux"
 	"github.com/urfave/cli/v2"
+	"github.com/writeas/web-core/log"
+	"github.com/writefreely/writefreely"
 )
 
 func main() {
 	cli.VersionPrinter = func(c *cli.Context) {
 		fmt.Printf("%s\n", c.App.Version)
 	}
 	app := &cli.App{
 		Name:    "WriteFreely",
 		Usage:   "A beautifully pared-down blogging platform",
 		Version: writefreely.FormatVersion(),
 		Action:  legacyActions, // legacy due to use of flags for switching actions
 		Flags: []cli.Flag{
 			&cli.BoolFlag{
 				Name:   "create-config",
 				Value:  false,
 				Usage:  "Generate a basic configuration",
 				Hidden: true,
 			},
 			&cli.BoolFlag{
 				Name:   "config",
 				Value:  false,
 				Usage:  "Interactive configuration process",
 				Hidden: true,
 			},
 			&cli.StringFlag{
 				Name:  "sections",
 				Value: "server db app",
 				Usage: "Which sections of the configuration to go through (requires --config)\n" +
 					"valid values are any combination of 'server', 'db' and 'app' \n" +
 					"example: writefreely --config --sections \"db app\"",
 				Hidden: true,
 			},
 			&cli.BoolFlag{
 				Name:   "gen-keys",
 				Value:  false,
 				Usage:  "Generate encryption and authentication keys",
 				Hidden: true,
 			},
 			&cli.BoolFlag{
 				Name:   "init-db",
 				Value:  false,
 				Usage:  "Initialize app database",
 				Hidden: true,
 			},
 			&cli.BoolFlag{
 				Name:   "migrate",
 				Value:  false,
 				Usage:  "Migrate the database",
 				Hidden: true,
 			},
 			&cli.StringFlag{
 				Name:   "create-admin",
 				Usage:  "Create an admin with the given username:password",
 				Hidden: true,
 			},
 			&cli.StringFlag{
 				Name:   "create-user",
 				Usage:  "Create a regular user with the given username:password",
 				Hidden: true,
 			},
 			&cli.StringFlag{
 				Name:   "delete-user",
 				Usage:  "Delete a user with the given username",
 				Hidden: true,
 			},
 			&cli.StringFlag{
 				Name:   "reset-pass",
 				Usage:  "Reset the given user's password",
 				Hidden: true,
 			},
 		}, // legacy flags (set to hidden to eventually switch to bash-complete compatible format)
 	}
 
 	defaultFlags := []cli.Flag{
 		&cli.StringFlag{
 			Name:  "c",
 			Value: "config.ini",
 			Usage: "Load configuration from `FILE`",
 		},
 		&cli.BoolFlag{
 			Name:  "debug",
 			Value: false,
 			Usage: "Enables debug logging",
 		},
 	}
 
 	app.Flags = append(app.Flags, defaultFlags...)
 
 	app.Commands = []*cli.Command{
 		&cmdUser,
 		&cmdDB,
 		&cmdConfig,
 		&cmdKeys,
 		&cmdServe,
 	}
 
 	err := app.Run(os.Args)
 	if err != nil {
 		log.Error(err.Error())
 		os.Exit(1)
 	}
 }
 
 func legacyActions(c *cli.Context) error {
 	app := writefreely.NewApp(c.String("c"))
 
 	switch true {
 	case c.IsSet("create-config"):
 		return writefreely.CreateConfig(app)
 	case c.IsSet("config"):
 		writefreely.DoConfig(app, c.String("sections"))
 		return nil
 	case c.IsSet("gen-keys"):
 		return writefreely.GenerateKeyFiles(app)
 	case c.IsSet("init-db"):
 		return writefreely.CreateSchema(app)
 	case c.IsSet("migrate"):
 		return writefreely.Migrate(app)
 	case c.IsSet("create-admin"):
 		username, password, err := parseCredentials(c.String("create-admin"))
 		if err != nil {
 			return err
 		}
 		return writefreely.CreateUser(app, username, password, true)
 	case c.IsSet("create-user"):
 		username, password, err := parseCredentials(c.String("create-user"))
 		if err != nil {
 			return err
 		}
 		return writefreely.CreateUser(app, username, password, false)
 	case c.IsSet("delete-user"):
 		return writefreely.DoDeleteAccount(app, c.String("delete-user"))
 	case c.IsSet("reset-pass"):
 		return writefreely.ResetPassword(app, c.String("reset-pass"))
 	}
 
 	// Initialize the application
 	var err error
 	log.Info("Starting %s...", writefreely.FormatVersion())
 	app, err = writefreely.Initialize(app, c.Bool("debug"))
 	if err != nil {
 		return err
 	}
 
 	// Set app routes
 	r := mux.NewRouter()
 	writefreely.InitRoutes(app, r)
 	app.InitStaticRoutes(r)
 
 	// Serve the application
 	writefreely.Serve(app, r)
 
 	return nil
 }
 
 func parseCredentials(credentialString string) (string, string, error) {
 	creds := strings.Split(credentialString, ":")
 	if len(creds) != 2 {
 		return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
 	}
 	return creds[0], creds[1], nil
 }
diff --git a/cmd/writefreely/user.go b/cmd/writefreely/user.go
index 58ecbfb..8429513 100644
--- a/cmd/writefreely/user.go
+++ b/cmd/writefreely/user.go
@@ -1,97 +1,96 @@
 /*
- * Copyright © 2020 A Bunch Tell LLC.
+ * Copyright © 2020-2021 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"
+	"github.com/writefreely/writefreely"
 )
 
 var (
 	cmdUser cli.Command = cli.Command{
 		Name:  "user",
 		Usage: "user management tools",
 		Subcommands: []*cli.Command{
 			&cmdAddUser,
 			&cmdDelUser,
 			&cmdResetPass,
 			// TODO: possibly add a user list command
 		},
 	}
 
 	cmdAddUser cli.Command = cli.Command{
 		Name:    "create",
 		Usage:   "Add new user",
 		Aliases: []string{"a", "add"},
 		Flags: []cli.Flag{
 			&cli.BoolFlag{
 				Name:  "admin",
 				Value: false,
 				Usage: "Create admin user",
 			},
 		},
 		Action: addUserAction,
 	}
 
 	cmdDelUser cli.Command = cli.Command{
 		Name:    "delete",
 		Usage:   "Delete user",
 		Aliases: []string{"del", "d"},
 		Action:  delUserAction,
 	}
 
 	cmdResetPass cli.Command = cli.Command{
 		Name:    "reset-pass",
 		Usage:   "Reset user's password",
 		Aliases: []string{"resetpass", "reset"},
 		Action:  resetPassAction,
 	}
 )
 
 func addUserAction(c *cli.Context) error {
 	credentials := ""
 	if c.NArg() > 0 {
 		credentials = c.Args().Get(0)
 	} else {
 		return fmt.Errorf("No user passed. Example: writefreely user add [USER]:[PASSWORD]")
 	}
 	username, password, err := parseCredentials(credentials)
 	if err != nil {
 		return err
 	}
 	app := writefreely.NewApp(c.String("c"))
 	return writefreely.CreateUser(app, username, password, c.Bool("admin"))
 }
 
 func delUserAction(c *cli.Context) error {
 	username := ""
 	if c.NArg() > 0 {
 		username = c.Args().Get(0)
 	} else {
 		return fmt.Errorf("No user passed. Example: writefreely user delete [USER]")
 	}
 	app := writefreely.NewApp(c.String("c"))
 	return writefreely.DoDeleteAccount(app, username)
 }
 
 func resetPassAction(c *cli.Context) error {
 	username := ""
 	if c.NArg() > 0 {
 		username = c.Args().Get(0)
 	} else {
 		return fmt.Errorf("No user passed. Example: writefreely user reset-pass [USER]")
 	}
 	app := writefreely.NewApp(c.String("c"))
 	return writefreely.ResetPassword(app, username)
 }
diff --git a/cmd/writefreely/web.go b/cmd/writefreely/web.go
index a687548..02ae1c9 100644
--- a/cmd/writefreely/web.go
+++ b/cmd/writefreely/web.go
@@ -1,49 +1,48 @@
 /*
- * Copyright © 2020 A Bunch Tell LLC.
+ * Copyright © 2020-2021 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"
+	"github.com/writeas/web-core/log"
+	"github.com/writefreely/writefreely"
 )
 
 var (
 	cmdServe cli.Command = cli.Command{
 		Name:    "serve",
 		Aliases: []string{"web"},
 		Usage:   "Run web application",
 		Action:  serveAction,
 	}
 )
 
 func serveAction(c *cli.Context) error {
 	// Initialize the application
 	app := writefreely.NewApp(c.String("c"))
 	var err error
 	log.Info("Starting %s...", writefreely.FormatVersion())
 	app, err = writefreely.Initialize(app, c.Bool("debug"))
 	if err != nil {
 		return err
 	}
 
 	// Set app routes
 	r := mux.NewRouter()
 	writefreely.InitRoutes(app, r)
 	app.InitStaticRoutes(r)
 
 	// Serve the application
 	writefreely.Serve(app, r)
 
 	return nil
 }
diff --git a/collections.go b/collections.go
index c7b84dc..a51df88 100644
--- a/collections.go
+++ b/collections.go
@@ -1,1165 +1,1165 @@
 /*
  * Copyright © 2018-2021 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 (
 	"database/sql"
 	"encoding/json"
 	"fmt"
 	"html/template"
 	"math"
 	"net/http"
 	"net/url"
 	"regexp"
 	"strconv"
 	"strings"
 	"unicode"
 
 	"github.com/gorilla/mux"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/activitystreams"
 	"github.com/writeas/web-core/auth"
 	"github.com/writeas/web-core/bots"
 	"github.com/writeas/web-core/log"
 	waposts "github.com/writeas/web-core/posts"
-	"github.com/writeas/writefreely/author"
-	"github.com/writeas/writefreely/config"
-	"github.com/writeas/writefreely/page"
+	"github.com/writefreely/writefreely/author"
+	"github.com/writefreely/writefreely/config"
+	"github.com/writefreely/writefreely/page"
 )
 
 type (
 	// TODO: add Direction to db
 	// TODO: add Language to db
 	Collection struct {
 		ID          int64          `datastore:"id" json:"-"`
 		Alias       string         `datastore:"alias" schema:"alias" json:"alias"`
 		Title       string         `datastore:"title" schema:"title" json:"title"`
 		Description string         `datastore:"description" schema:"description" json:"description"`
 		Direction   string         `schema:"dir" json:"dir,omitempty"`
 		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"`
 		Views       int64          `json:"views"`
 		OwnerID     int64          `datastore:"owner_id" json:"-"`
 		PublicOwner bool           `datastore:"public_owner" json:"-"`
 		URL         string         `json:"url,omitempty"`
 
 		MonetizationPointer string `json:"monetization_pointer,omitempty"`
 
 		db       *datastore
 		hostName string
 	}
 	CollectionObj struct {
 		Collection
 		TotalPosts int           `json:"total_posts"`
 		Owner      *User         `json:"owner,omitempty"`
 		Posts      *[]PublicPost `json:"posts,omitempty"`
 		Format     *CollectionFormat
 	}
 	DisplayCollection struct {
 		*CollectionObj
 		Prefix      string
 		IsTopLevel  bool
 		CurrentPage int
 		TotalPages  int
 		Silenced    bool
 	}
 	SubmittedCollection struct {
 		// Data used for updating a given collection
 		ID      int64
 		OwnerID uint64
 
 		// Form helpers
 		PreferURL string `schema:"prefer_url" json:"prefer_url"`
 		Privacy   int    `schema:"privacy" json:"privacy"`
 		Pass      string `schema:"password" json:"password"`
 		MathJax   bool   `schema:"mathjax" json:"mathjax"`
 		Handle    string `schema:"handle" json:"handle"`
 
 		// Actual collection values updated in the DB
 		Alias        *string         `schema:"alias" json:"alias"`
 		Title        *string         `schema:"title" json:"title"`
 		Description  *string         `schema:"description" json:"description"`
 		StyleSheet   *sql.NullString `schema:"style_sheet" json:"style_sheet"`
 		Script       *sql.NullString `schema:"script" json:"script"`
 		Signature    *sql.NullString `schema:"signature" json:"signature"`
 		Monetization *string         `schema:"monetization_pointer" json:"monetization_pointer"`
 		Visibility   *int            `schema:"visibility" json:"public"`
 		Format       *sql.NullString `schema:"format" json:"format"`
 	}
 	CollectionFormat struct {
 		Format string
 	}
 
 	collectionReq struct {
 		// Information about the collection request itself
 		prefix, alias, domain string
 		isCustomDomain        bool
 
 		// User-related fields
 		isCollOwner bool
 	}
 )
 
 func (sc *SubmittedCollection) FediverseHandle() string {
 	if sc.Handle == "" {
 		return apCustomHandleDefault
 	}
 	return getSlug(sc.Handle, "")
 }
 
 // collVisibility represents the visibility level for the collection.
 type collVisibility int
 
 // Visibility levels. Values are bitmasks, stored in the database as
 // decimal numbers. If adding types, append them to this list. If removing,
 // replace the desired visibility with a new value.
 const CollUnlisted collVisibility = 0
 const (
 	CollPublic collVisibility = 1 << iota
 	CollPrivate
 	CollProtected
 )
 
 var collVisibilityStrings = map[string]collVisibility{
 	"unlisted":  CollUnlisted,
 	"public":    CollPublic,
 	"private":   CollPrivate,
 	"protected": CollProtected,
 }
 
 func defaultVisibility(cfg *config.Config) collVisibility {
 	vis, ok := collVisibilityStrings[cfg.App.DefaultVisibility]
 	if !ok {
 		vis = CollUnlisted
 	}
 	return vis
 }
 
 func (cf *CollectionFormat) Ascending() bool {
 	return cf.Format == "novel"
 }
 func (cf *CollectionFormat) ShowDates() bool {
 	return cf.Format == "blog"
 }
 func (cf *CollectionFormat) PostsPerPage() int {
 	if cf.Format == "novel" {
 		return postsPerPage
 	}
 	return postsPerPage
 }
 
 // Valid returns whether or not a format value is valid.
 func (cf *CollectionFormat) Valid() bool {
 	return cf.Format == "blog" ||
 		cf.Format == "novel" ||
 		cf.Format == "notebook"
 }
 
 // NewFormat creates a new CollectionFormat object from the Collection.
 func (c *Collection) NewFormat() *CollectionFormat {
 	cf := &CollectionFormat{Format: c.Format}
 
 	// Fill in default format
 	if cf.Format == "" {
 		cf.Format = "blog"
 	}
 
 	return cf
 }
 
 func (c *Collection) IsInstanceColl() bool {
 	ur, _ := url.Parse(c.hostName)
 	return c.Alias == ur.Host
 }
 
 func (c *Collection) IsUnlisted() bool {
 	return c.Visibility == 0
 }
 
 func (c *Collection) IsPrivate() bool {
 	return c.Visibility&CollPrivate != 0
 }
 
 func (c *Collection) IsProtected() bool {
 	return c.Visibility&CollProtected != 0
 }
 
 func (c *Collection) IsPublic() bool {
 	return c.Visibility&CollPublic != 0
 }
 
 func (c *Collection) FriendlyVisibility() string {
 	if c.IsPrivate() {
 		return "Private"
 	}
 	if c.IsPublic() {
 		return "Public"
 	}
 	if c.IsProtected() {
 		return "Password-protected"
 	}
 	return "Unlisted"
 }
 
 func (c *Collection) ShowFooterBranding() bool {
 	// TODO: implement this setting
 	return true
 }
 
 // CanonicalURL returns a fully-qualified URL to the collection.
 func (c *Collection) CanonicalURL() string {
 	return c.RedirectingCanonicalURL(false)
 }
 
 func (c *Collection) DisplayCanonicalURL() string {
 	us := c.CanonicalURL()
 	u, err := url.Parse(us)
 	if err != nil {
 		return us
 	}
 	p := u.Path
 	if p == "/" {
 		p = ""
 	}
 	return u.Hostname() + p
 }
 
 func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
 	if c.hostName == "" {
 		// If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail
-		log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writeas/writefreely/issues/new?template=bug_report.md")
+		log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writefreely/writefreely/issues/new?template=bug_report.md")
 	}
 	if isSingleUser {
 		return c.hostName + "/"
 	}
 
 	return fmt.Sprintf("%s/%s/", c.hostName, c.Alias)
 }
 
 // PrevPageURL provides a full URL for the previous page of collection posts,
 // returning a /page/N result for pages >1
 func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
 	u := ""
 	if n == 2 {
 		// Previous page is 1; no need for /page/ prefix
 		if prefix == "" {
 			u = "/"
 		}
 		// Else leave off trailing slash
 	} else {
 		u = fmt.Sprintf("/page/%d", n-1)
 	}
 
 	if tl {
 		return u
 	}
 	return "/" + prefix + c.Alias + u
 }
 
 // NextPageURL provides a full URL for the next page of collection posts
 func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
 	if tl {
 		return fmt.Sprintf("/page/%d", n+1)
 	}
 	return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
 }
 
 func (c *Collection) DisplayTitle() string {
 	if c.Title != "" {
 		return c.Title
 	}
 	return c.Alias
 }
 
 func (c *Collection) StyleSheetDisplay() template.CSS {
 	return template.CSS(c.StyleSheet)
 }
 
 // ForPublic modifies the Collection for public consumption, such as via
 // the API.
 func (c *Collection) ForPublic() {
 	c.URL = c.CanonicalURL()
 }
 
 var isAvatarChar = regexp.MustCompile("[a-z0-9]").MatchString
 
 func (c *Collection) PersonObject(ids ...int64) *activitystreams.Person {
 	accountRoot := c.FederatedAccount()
 	p := activitystreams.NewPerson(accountRoot)
 	p.URL = c.CanonicalURL()
 	uname := c.Alias
 	p.PreferredUsername = uname
 	p.Name = c.DisplayTitle()
 	p.Summary = c.Description
 	if p.Name != "" {
 		if av := c.AvatarURL(); av != "" {
 			p.Icon = activitystreams.Image{
 				Type:      "Image",
 				MediaType: "image/png",
 				URL:       av,
 			}
 		}
 	}
 
 	collID := c.ID
 	if len(ids) > 0 {
 		collID = ids[0]
 	}
 	pub, priv := c.db.GetAPActorKeys(collID)
 	if pub != nil {
 		p.AddPubKey(pub)
 		p.SetPrivKey(priv)
 	}
 
 	return p
 }
 
 func (c *Collection) AvatarURL() string {
 	fl := string(unicode.ToLower([]rune(c.DisplayTitle())[0]))
 	if !isAvatarChar(fl) {
 		return ""
 	}
 	return c.hostName + "/img/avatars/" + fl + ".png"
 }
 
 func (c *Collection) FederatedAPIBase() string {
 	return c.hostName + "/"
 }
 
 func (c *Collection) FederatedAccount() string {
 	accountUser := c.Alias
 	return c.FederatedAPIBase() + "api/collections/" + accountUser
 }
 
 func (c *Collection) RenderMathJax() bool {
 	return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
 }
 
 func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	alias := r.FormValue("alias")
 	title := r.FormValue("title")
 
 	var missingParams, accessToken string
 	var u *User
 	c := struct {
 		Alias string `json:"alias" schema:"alias"`
 		Title string `json:"title" schema:"title"`
 		Web   bool   `json:"web" schema:"web"`
 	}{}
 	if reqJSON {
 		// Decode JSON request
 		decoder := json.NewDecoder(r.Body)
 		err := decoder.Decode(&c)
 		if err != nil {
 			log.Error("Couldn't parse post update JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 	} else {
 		// TODO: move form parsing to formDecoder
 		c.Alias = alias
 		c.Title = title
 	}
 
 	if c.Alias == "" {
 		if c.Title != "" {
 			// If only a title was given, just use it to generate the alias.
 			c.Alias = getSlug(c.Title, "")
 		} else {
 			missingParams += "`alias` "
 		}
 	}
 	if c.Title == "" {
 		missingParams += "`title` "
 	}
 	if missingParams != "" {
 		return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Parameter(s) %srequired.", missingParams)}
 	}
 
 	var userID int64
 	var err error
 	if reqJSON && !c.Web {
 		accessToken = r.Header.Get("Authorization")
 		if accessToken == "" {
 			return ErrNoAccessToken
 		}
 		userID = app.db.GetUserID(accessToken)
 		if userID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u = getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 		userID = u.ID
 	}
 	silenced, err := app.db.IsUserSilenced(userID)
 	if err != nil {
 		log.Error("new collection: %v", err)
 		return ErrInternalGeneral
 	}
 	if silenced {
 		return ErrUserSilenced
 	}
 
 	if !author.IsValidUsername(app.cfg, c.Alias) {
 		return impart.HTTPError{http.StatusPreconditionFailed, "Collection alias isn't valid."}
 	}
 
 	coll, err := app.db.CreateCollection(app.cfg, c.Alias, c.Title, userID)
 	if err != nil {
 		// TODO: handle this
 		return err
 	}
 
 	res := &CollectionObj{Collection: *coll}
 
 	if reqJSON {
 		return impart.WriteSuccess(w, res, http.StatusCreated)
 	}
 	redirectTo := "/me/c/"
 	// TODO: redirect to pad when necessary
 	return impart.HTTPError{http.StatusFound, redirectTo}
 }
 
 func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (int64, error) {
 	accessToken := r.Header.Get("Authorization")
 	var userID int64 = -1
 	if accessToken != "" {
 		userID = app.db.GetUserID(accessToken)
 	}
 	isCollOwner := userID == c.OwnerID
 	if c.IsPrivate() && !isCollOwner {
 		// Collection is private, but user isn't authenticated
 		return -1, ErrCollectionNotFound
 	}
 	if c.IsProtected() {
 		// TODO: check access token
 		return -1, ErrCollectionUnauthorizedRead
 	}
 
 	return userID, nil
 }
 
 // fetchCollection handles the API endpoint for retrieving collection data.
 func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
 	accept := r.Header.Get("Accept")
 	if strings.Contains(accept, "application/activity+json") {
 		return handleFetchCollectionActivities(app, w, r)
 	}
 
 	vars := mux.Vars(r)
 	alias := vars["alias"]
 
 	// TODO: move this logic into a common getCollection function
 	// Get base Collection data
 	c, err := app.db.GetCollection(alias)
 	if err != nil {
 		return err
 	}
 	c.hostName = app.cfg.App.Host
 
 	// Redirect users who aren't requesting JSON
 	reqJSON := IsJSON(r)
 	if !reqJSON {
 		return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
 	}
 
 	// Check permissions
 	userID, err := apiCheckCollectionPermissions(app, r, c)
 	if err != nil {
 		return err
 	}
 	isCollOwner := userID == c.OwnerID
 
 	// Fetch extra data about the Collection
 	res := &CollectionObj{Collection: *c}
 	if c.PublicOwner {
 		u, err := app.db.GetUserByID(res.OwnerID)
 		if err != nil {
 			// Log the error and just continue
 			log.Error("Error getting user for collection: %v", err)
 		} else {
 			res.Owner = u
 		}
 	}
 	// TODO: check status for silenced
 	app.db.GetPostsCount(res, isCollOwner)
 	// Strip non-public information
 	res.Collection.ForPublic()
 
 	return impart.WriteSuccess(w, res, http.StatusOK)
 }
 
 // fetchCollectionPosts handles an API endpoint for retrieving a collection's
 // posts.
 func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	alias := vars["alias"]
 
 	c, err := app.db.GetCollection(alias)
 	if err != nil {
 		return err
 	}
 	c.hostName = app.cfg.App.Host
 
 	// Check permissions
 	userID, err := apiCheckCollectionPermissions(app, r, c)
 	if err != nil {
 		return err
 	}
 	isCollOwner := userID == c.OwnerID
 
 	// Get page
 	page := 1
 	if p := r.FormValue("page"); p != "" {
 		pInt, _ := strconv.Atoi(p)
 		if pInt > 0 {
 			page = pInt
 		}
 	}
 
 	posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
 	if err != nil {
 		return err
 	}
 	coll := &CollectionObj{Collection: *c, Posts: posts}
 	app.db.GetPostsCount(coll, isCollOwner)
 	// Strip non-public information
 	coll.Collection.ForPublic()
 
 	// Transform post bodies if needed
 	if r.FormValue("body") == "html" {
 		for _, p := range *coll.Posts {
 			p.Content = waposts.ApplyMarkdown([]byte(p.Content))
 		}
 	}
 
 	return impart.WriteSuccess(w, coll, http.StatusOK)
 }
 
 type CollectionPage struct {
 	page.StaticPage
 	*DisplayCollection
 	IsCustomDomain bool
 	IsWelcome      bool
 	IsOwner        bool
 	CanPin         bool
 	Username       string
 	Monetization   string
 	Collections    *[]Collection
 	PinnedPosts    *[]PublicPost
 	IsAdmin        bool
 	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)
 }
 
 var jsSourceCommentReg = regexp.MustCompile("(?m)^// src:(.+)$")
 
 func (c *CollectionObj) ExternalScripts() []template.URL {
 	scripts := []template.URL{}
 	if c.Script == "" {
 		return scripts
 	}
 
 	matches := jsSourceCommentReg.FindAllStringSubmatch(c.Script, -1)
 	for _, m := range matches {
 		scripts = append(scripts, template.URL(strings.TrimSpace(m[1])))
 	}
 	return scripts
 }
 
 func (c *CollectionObj) CanShowScript() bool {
 	return false
 }
 
 func processCollectionRequest(cr *collectionReq, vars map[string]string, w http.ResponseWriter, r *http.Request) error {
 	cr.prefix = vars["prefix"]
 	cr.alias = vars["collection"]
 	// Normalize the URL, redirecting user to consistent post URL
 	if cr.alias != strings.ToLower(cr.alias) {
 		return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", strings.ToLower(cr.alias))}
 	}
 
 	return nil
 }
 
 // processCollectionPermissions checks the permissions for the given
 // collectionReq, returning a Collection if access is granted; otherwise this
 // renders any necessary collection pages, for example, if requesting a custom
 // domain that doesn't yet have a collection associated, or if a collection
 // requires a password. In either case, this will return nil, nil -- thus both
 // values should ALWAYS be checked to determine whether or not to continue.
 func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.ResponseWriter, r *http.Request) (*Collection, error) {
 	// Display collection if this is a collection
 	var c *Collection
 	var err error
 	if app.cfg.App.SingleUser {
 		c, err = app.db.GetCollectionByID(1)
 	} else {
 		c, err = app.db.GetCollection(cr.alias)
 	}
 	// TODO: verify we don't reveal the existence of a private collection with redirection
 	if err != nil {
 		if err, ok := err.(impart.HTTPError); ok {
 			if err.Status == http.StatusNotFound {
 				if cr.isCustomDomain {
 					// User is on the site from a custom domain
 					//tErr := pages["404-domain.tmpl"].ExecuteTemplate(w, "base", pageForHost(page.StaticPage{}, r))
 					//if tErr != nil {
 					//log.Error("Unable to render 404-domain page: %v", err)
 					//}
 					return nil, nil
 				}
 				if len(cr.alias) >= minIDLen && len(cr.alias) <= maxIDLen {
 					// Alias is within post ID range, so just be sure this isn't a post
 					if app.db.PostIDExists(cr.alias) {
 						// TODO: use StatusFound for vanity post URLs when we implement them
 						return nil, impart.HTTPError{http.StatusMovedPermanently, "/" + cr.alias}
 					}
 				}
 				// Redirect if necessary
 				newAlias := app.db.GetCollectionRedirect(cr.alias)
 				if newAlias != "" {
 					return nil, impart.HTTPError{http.StatusFound, "/" + newAlias + "/"}
 				}
 			}
 		}
 		return nil, err
 	}
 	c.hostName = app.cfg.App.Host
 
 	// Update CollectionRequest to reflect owner status
 	cr.isCollOwner = u != nil && u.ID == c.OwnerID
 
 	// Check permissions
 	if !cr.isCollOwner {
 		if c.IsPrivate() {
 			return nil, ErrCollectionNotFound
 		} else if c.IsProtected() {
 			uname := ""
 			if u != nil {
 				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)
 
 			if !authd {
 				p := struct {
 					page.StaticPage
 					*CollectionObj
 					Username string
 					Next     string
 					Flashes  []template.HTML
 				}{
 					StaticPage:    pageForReq(app, r),
 					CollectionObj: &CollectionObj{Collection: *c},
 					Username:      uname,
 					Next:          r.FormValue("g"),
 					Flashes:       []template.HTML{},
 				}
 				// Get owner information
 				p.CollectionObj.Owner, err = app.db.GetUserByID(c.OwnerID)
 				if err != nil {
 					// Log the error and just continue
 					log.Error("Error getting user for collection: %v", err)
 				}
 
 				flashes, _ := getSessionFlashes(app, w, r, nil)
 				for _, flash := range flashes {
 					p.Flashes = append(p.Flashes, template.HTML(flash))
 				}
 				err = templates["password-collection"].ExecuteTemplate(w, "password-collection", p)
 				if err != nil {
 					log.Error("Unable to render password-collection: %v", err)
 					return nil, err
 				}
 				return nil, nil
 			}
 		}
 	}
 	return c, nil
 }
 
 func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPostReq bool) (*User, error) {
 	u := getUserSession(app, r)
 	return u, nil
 }
 
 func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
 	coll := &DisplayCollection{
 		CollectionObj: NewCollectionObj(c),
 		CurrentPage:   page,
 		Prefix:        cr.prefix,
 		IsTopLevel:    isSingleUser,
 	}
 	c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
 	return coll
 }
 
 // getCollectionPage returns the collection page as an int. If the parsed page value is not
 // greater than 0 then the default value of 1 is returned.
 func getCollectionPage(vars map[string]string) int {
 	if p, _ := strconv.Atoi(vars["page"]); p > 0 {
 		return p
 	}
 
 	return 1
 }
 
 // handleViewCollection displays the requested Collection
 func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	cr := &collectionReq{}
 
 	err := processCollectionRequest(cr, vars, w, r)
 	if err != nil {
 		return err
 	}
 
 	u, err := checkUserForCollection(app, cr, r, false)
 	if err != nil {
 		return err
 	}
 
 	page := getCollectionPage(vars)
 
 	c, err := processCollectionPermissions(app, cr, u, w, r)
 	if c == nil || err != nil {
 		return err
 	}
 	c.hostName = app.cfg.App.Host
 
 	silenced, err := app.db.IsUserSilenced(c.OwnerID)
 	if err != nil {
 		log.Error("view collection: %v", err)
 		return ErrInternalGeneral
 	}
 
 	// Serve ActivityStreams data now, if requested
 	if 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)
 	}
 
 	// Fetch extra data about the Collection
 	// TODO: refactor out this logic, shared in collection.go:fetchCollection()
 	coll := newDisplayCollection(c, cr, page)
 
 	coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
 	if coll.TotalPages > 0 && page > coll.TotalPages {
 		redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
 		if !app.cfg.App.SingleUser {
 			redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
 		}
 		return impart.HTTPError{http.StatusFound, redirURL}
 	}
 
 	coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
 
 	// Serve collection
 	displayPage := CollectionPage{
 		DisplayCollection: coll,
 		StaticPage:        pageForReq(app, r),
 		IsCustomDomain:    cr.isCustomDomain,
 		IsWelcome:         r.FormValue("greeting") != "",
 	}
 	displayPage.IsAdmin = u != nil && u.IsAdmin()
 	displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
 	var owner *User
 	if u != nil {
 		displayPage.Username = u.Username
 		displayPage.IsOwner = u.ID == coll.OwnerID
 		if displayPage.IsOwner {
 			// Add in needed information for users viewing their own collection
 			owner = u
 			displayPage.CanPin = true
 
 			pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
 			if err != nil {
 				log.Error("unable to fetch collections: %v", err)
 			}
 			displayPage.Collections = pubColls
 		}
 	}
 	isOwner := owner != nil
 	if !isOwner {
 		// Current user doesn't own collection; retrieve owner information
 		owner, err = app.db.GetUserByID(coll.OwnerID)
 		if err != nil {
 			// Log the error and just continue
 			log.Error("Error getting user for collection: %v", err)
 		}
 	}
 	if !isOwner && silenced {
 		return ErrCollectionNotFound
 	}
 	displayPage.Silenced = isOwner && silenced
 	displayPage.Owner = owner
 	coll.Owner = displayPage.Owner
 
 	// Add more data
 	// TODO: fix this mess of collections inside collections
 	displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
 	displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
 
 	collTmpl := "collection"
 	if app.cfg.App.Chorus {
 		collTmpl = "chorus-collection"
 	}
 	err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
 	if err != nil {
 		log.Error("Unable to render collection index: %v", err)
 	}
 
 	// Update collection view count
 	go func() {
 		// Don't update if owner is viewing the collection.
 		if u != nil && u.ID == coll.OwnerID {
 			return
 		}
 		// Only update for human views
 		if r.Method == "HEAD" || bots.IsBot(r.UserAgent()) {
 			return
 		}
 
 		_, err := app.db.Exec("UPDATE collections SET view_count = view_count + 1 WHERE id = ?", coll.ID)
 		if err != nil {
 			log.Error("Unable to update collections count: %v", err)
 		}
 	}()
 
 	return err
 }
 
 func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	handle := vars["handle"]
 
 	remoteUser, err := app.db.GetProfilePageFromHandle(app, handle)
 	if err != nil || remoteUser == "" {
 		log.Error("Couldn't find user %s: %v", handle, err)
 		return ErrRemoteUserNotFound
 	}
 
 	return impart.HTTPError{Status: http.StatusFound, Message: remoteUser}
 }
 
 func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	tag := vars["tag"]
 
 	cr := &collectionReq{}
 	err := processCollectionRequest(cr, vars, w, r)
 	if err != nil {
 		return err
 	}
 
 	u, err := checkUserForCollection(app, cr, r, false)
 	if err != nil {
 		return err
 	}
 
 	page := getCollectionPage(vars)
 
 	c, err := processCollectionPermissions(app, cr, u, w, r)
 	if c == nil || err != nil {
 		return err
 	}
 
 	coll := newDisplayCollection(c, cr, page)
 
 	coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
 	if coll.Posts != nil && len(*coll.Posts) == 0 {
 		return ErrCollectionPageNotFound
 	}
 
 	// Serve collection
 	displayPage := struct {
 		CollectionPage
 		Tag string
 	}{
 		CollectionPage: CollectionPage{
 			DisplayCollection: coll,
 			StaticPage:        pageForReq(app, r),
 			IsCustomDomain:    cr.isCustomDomain,
 		},
 		Tag: tag,
 	}
 	var owner *User
 	if u != nil {
 		displayPage.Username = u.Username
 		displayPage.IsOwner = u.ID == coll.OwnerID
 		if displayPage.IsOwner {
 			// Add in needed information for users viewing their own collection
 			owner = u
 			displayPage.CanPin = true
 
 			pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
 			if err != nil {
 				log.Error("unable to fetch collections: %v", err)
 			}
 			displayPage.Collections = pubColls
 		}
 	}
 	isOwner := owner != nil
 	if !isOwner {
 		// Current user doesn't own collection; retrieve owner information
 		owner, err = app.db.GetUserByID(coll.OwnerID)
 		if err != nil {
 			// Log the error and just continue
 			log.Error("Error getting user for collection: %v", err)
 		}
 		if owner.IsSilenced() {
 			return ErrCollectionNotFound
 		}
 	}
 	displayPage.Silenced = owner != nil && owner.IsSilenced()
 	displayPage.Owner = owner
 	coll.Owner = displayPage.Owner
 	// Add more data
 	// TODO: fix this mess of collections inside collections
 	displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
 	displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
 
 	err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
 	if err != nil {
 		log.Error("Unable to render collection tag page: %v", err)
 	}
 
 	return nil
 }
 
 func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	slug := vars["slug"]
 
 	cr := &collectionReq{}
 	err := processCollectionRequest(cr, vars, w, r)
 	if err != nil {
 		return err
 	}
 
 	// Normalize the URL, redirecting user to consistent post URL
 	loc := fmt.Sprintf("/%s", slug)
 	if !app.cfg.App.SingleUser {
 		loc = fmt.Sprintf("/%s/%s", cr.alias, slug)
 	}
 	return impart.HTTPError{http.StatusFound, loc}
 }
 
 func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	vars := mux.Vars(r)
 	collAlias := vars["alias"]
 	isWeb := r.FormValue("web") == "1"
 
 	u := &User{}
 	if reqJSON && !isWeb {
 		// Ensure an access token was given
 		accessToken := r.Header.Get("Authorization")
 		u.ID = app.db.GetUserID(accessToken)
 		if u.ID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u = getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 	}
 
 	silenced, err := app.db.IsUserSilenced(u.ID)
 	if err != nil {
 		log.Error("existing collection: %v", err)
 		return ErrInternalGeneral
 	}
 
 	if silenced {
 		return ErrUserSilenced
 	}
 
 	if r.Method == "DELETE" {
 		err := app.db.DeleteCollection(collAlias, u.ID)
 		if err != nil {
 			// TODO: if not HTTPError, report error to admin
 			log.Error("Unable to delete collection: %s", err)
 			return err
 		}
 		addSessionFlash(app, w, r, "Deleted your blog, "+collAlias+".", nil)
 		return impart.HTTPError{Status: http.StatusNoContent}
 	}
 
 	c := SubmittedCollection{OwnerID: uint64(u.ID)}
 
 	if reqJSON {
 		// Decode JSON request
 		decoder := json.NewDecoder(r.Body)
 		err = decoder.Decode(&c)
 		if err != nil {
 			log.Error("Couldn't parse collection update JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 	} else {
 		err = r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse collection update form request: %v\n", err)
 			return ErrBadFormData
 		}
 
 		err = app.formDecoder.Decode(&c, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode collection update form request: %v\n", err)
 			return ErrBadFormData
 		}
 	}
 
 	err = app.db.UpdateCollection(&c, collAlias)
 	if err != nil {
 		if err, ok := err.(impart.HTTPError); ok {
 			if reqJSON {
 				return err
 			}
 			addSessionFlash(app, w, r, err.Message, nil)
 			return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
 		} else {
 			log.Error("Couldn't update collection: %v\n", err)
 			return err
 		}
 	}
 
 	if reqJSON {
 		return impart.WriteSuccess(w, struct {
 		}{}, http.StatusOK)
 	}
 
 	addSessionFlash(app, w, r, "Blog updated!", nil)
 	return impart.HTTPError{http.StatusFound, "/me/c/" + collAlias}
 }
 
 // collectionAliasFromReq takes a request and returns the collection alias
 // if it can be ascertained, as well as whether or not the collection uses a
 // custom domain.
 func collectionAliasFromReq(r *http.Request) string {
 	vars := mux.Vars(r)
 	alias := vars["subdomain"]
 	isSubdomain := alias != ""
 	if !isSubdomain {
 		// Fall back to write.as/{collection} since this isn't a custom domain
 		alias = vars["collection"]
 	}
 	return alias
 }
 
 func handleWebCollectionUnlock(app *App, w http.ResponseWriter, r *http.Request) error {
 	var readReq struct {
 		Alias string `schema:"alias" json:"alias"`
 		Pass  string `schema:"password" json:"password"`
 		Next  string `schema:"to" json:"to"`
 	}
 
 	// Get params
 	if impart.ReqJSON(r) {
 		decoder := json.NewDecoder(r.Body)
 		err := decoder.Decode(&readReq)
 		if err != nil {
 			log.Error("Couldn't parse readReq JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 	} else {
 		err := r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse readReq form request: %v\n", err)
 			return ErrBadFormData
 		}
 
 		err = app.formDecoder.Decode(&readReq, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode readReq form request: %v\n", err)
 			return ErrBadFormData
 		}
 	}
 
 	if readReq.Alias == "" {
 		return impart.HTTPError{http.StatusBadRequest, "Need a collection `alias` to read."}
 	}
 	if readReq.Pass == "" {
 		return impart.HTTPError{http.StatusBadRequest, "Please supply a password."}
 	}
 
 	var collHashedPass []byte
 	err := app.db.QueryRow("SELECT password FROM collectionpasswords INNER JOIN collections ON id = collection_id WHERE alias = ?", readReq.Alias).Scan(&collHashedPass)
 	if err != nil {
 		if err == sql.ErrNoRows {
 			log.Error("No collectionpassword found when trying to read collection %s", readReq.Alias)
 			return impart.HTTPError{http.StatusInternalServerError, "Something went very wrong. The humans have been alerted."}
 		}
 		return err
 	}
 
 	if !auth.Authenticated(collHashedPass, []byte(readReq.Pass)) {
 		return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
 	}
 
 	// Success; set cookie
 	session, err := app.sessionStore.Get(r, blogPassCookieName)
 	if err == nil {
 		session.Values[readReq.Alias] = true
 		err = session.Save(r, w)
 		if err != nil {
 			log.Error("Didn't save unlocked blog '%s': %v", readReq.Alias, err)
 		}
 	}
 
 	next := "/" + readReq.Next
 	if !app.cfg.App.SingleUser {
 		next = "/" + readReq.Alias + next
 	}
 	return impart.HTTPError{http.StatusFound, next}
 }
 
 func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
 	authd := false
 	session, err := app.sessionStore.Get(r, blogPassCookieName)
 	if err == nil {
 		_, authd = session.Values[alias]
 	}
 	return authd
 }
diff --git a/database.go b/database.go
index b85bb27..86fa271 100644
--- a/database.go
+++ b/database.go
@@ -1,2790 +1,2790 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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"
 	"database/sql"
 	"fmt"
 	"github.com/writeas/web-core/silobridge"
-	wf_db "github.com/writeas/writefreely/db"
+	wf_db "github.com/writefreely/writefreely/db"
 	"net/http"
 	"strings"
 	"time"
 
 	"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"
 	"github.com/writeas/web-core/auth"
 	"github.com/writeas/web-core/data"
 	"github.com/writeas/web-core/id"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/web-core/query"
-	"github.com/writeas/writefreely/author"
-	"github.com/writeas/writefreely/config"
-	"github.com/writeas/writefreely/key"
+	"github.com/writefreely/writefreely/author"
+	"github.com/writefreely/writefreely/config"
+	"github.com/writefreely/writefreely/key"
 )
 
 const (
 	mySQLErrDuplicateKey = 1062
 	mySQLErrCollationMix = 1267
 	mySQLErrTooManyConns = 1040
 	mySQLErrMaxUserConns = 1203
 
 	driverMySQL  = "mysql"
 	driverSQLite = "sqlite3"
 )
 
 var (
 	SQLiteEnabled bool
 )
 
 type writestore interface {
 	CreateUser(*config.Config, *User, string) error
 	UpdateUserEmail(keys *key.Keychain, userID int64, email string) error
 	UpdateEncryptedUserEmail(int64, []byte) error
 	GetUserByID(int64) (*User, error)
 	GetUserForAuth(string) (*User, error)
 	GetUserForAuthByID(int64) (*User, error)
 	GetUserNameFromToken(string) (string, error)
 	GetUserDataFromToken(string) (int64, string, error)
 	GetAPIUser(header string) (*User, error)
 	GetUserID(accessToken string) int64
 	GetUserIDPrivilege(accessToken string) (userID int64, sudo bool)
 	DeleteToken(accessToken []byte) error
 	FetchLastAccessToken(userID int64) string
 	GetAccessToken(userID int64) (string, error)
 	GetTemporaryAccessToken(userID int64, validSecs int) (string, error)
 	GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error)
 	DeleteAccount(userID int64) error
 	ChangeSettings(app *App, u *User, s *userSettings) error
 	ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error
 
 	GetCollections(u *User, hostName string) (*[]Collection, error)
 	GetPublishableCollections(u *User, hostName string) (*[]Collection, error)
 	GetMeStats(u *User) userMeStats
 	GetTotalCollections() (int64, error)
 	GetTotalPosts() (int64, error)
 	GetTopPosts(u *User, alias string) (*[]PublicPost, error)
 	GetAnonymousPosts(u *User) (*[]PublicPost, error)
 	GetUserPosts(u *User) (*[]PublicPost, error)
 
 	CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error)
 	CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error)
 	UpdateOwnedPost(post *AuthenticatedPost, userID int64) error
 	GetEditablePost(id, editToken string) (*PublicPost, error)
 	PostIDExists(id string) bool
 	GetPost(id string, collectionID int64) (*PublicPost, error)
 	GetOwnedPost(id string, ownerID int64) (*PublicPost, error)
 	GetPostProperty(id string, collectionID int64, property string) (interface{}, error)
 
 	CreateCollectionFromToken(*config.Config, string, string, string) (*Collection, error)
 	CreateCollection(*config.Config, string, string, int64) (*Collection, error)
 	GetCollectionBy(condition string, value interface{}) (*Collection, error)
 	GetCollection(alias string) (*Collection, error)
 	GetCollectionForPad(alias string) (*Collection, error)
 	GetCollectionByID(id int64) (*Collection, error)
 	UpdateCollection(c *SubmittedCollection, alias string) error
 	DeleteCollection(alias string, userID int64) error
 
 	UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error
 	GetLastPinnedPostPos(collID int64) int64
 	GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[]PublicPost, error)
 	RemoveCollectionRedirect(t *sql.Tx, alias string) error
 	GetCollectionRedirect(alias string) (new string)
 	IsCollectionAttributeOn(id int64, attr string) bool
 	CollectionHasAttribute(id int64, attr string) bool
 
 	CanCollect(cpr *ClaimPostRequest, userID int64) bool
 	AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error)
 	DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error)
 	ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error)
 
 	GetPostsCount(c *CollectionObj, includeFuture bool)
 	GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error)
 	GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error)
 
 	GetAPFollowers(c *Collection) (*[]RemoteUser, error)
 	GetAPActorKeys(collectionID int64) ([]byte, []byte)
 	CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error
 	GetUserInvites(userID int64) (*[]Invite, error)
 	GetUserInvite(id string) (*Invite, error)
 	GetUsersInvitedCount(id string) int64
 	CreateInvitedUser(inviteID string, userID int64) error
 
 	GetDynamicContent(id string) (*instanceContent, error)
 	UpdateDynamicContent(id, title, content, contentType string) error
 	GetAllUsers(page uint) (*[]User, error)
 	GetAllUsersCount() int64
 	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
 }
 
 type datastore struct {
 	*sql.DB
 	driverName string
 }
 
 var _ writestore = &datastore{}
 
 func (db *datastore) now() string {
 	if db.driverName == driverSQLite {
 		return "strftime('%Y-%m-%d %H:%M:%S','now')"
 	}
 	return "NOW()"
 }
 
 func (db *datastore) clip(field string, l int) string {
 	if db.driverName == driverSQLite {
 		return fmt.Sprintf("SUBSTR(%s, 0, %d)", field, l)
 	}
 	return fmt.Sprintf("LEFT(%s, %d)", field, l)
 }
 
 func (db *datastore) upsert(indexedCols ...string) string {
 	if db.driverName == driverSQLite {
 		// NOTE: SQLite UPSERT syntax only works in v3.24.0 (2018-06-04) or later
 		// Leaving this for whenever we can upgrade and include it in our binary
 		cc := strings.Join(indexedCols, ", ")
 		return "ON CONFLICT(" + cc + ") DO UPDATE SET"
 	}
 	return "ON DUPLICATE KEY UPDATE"
 }
 
 func (db *datastore) dateSub(l int, unit string) string {
 	if db.driverName == driverSQLite {
 		return fmt.Sprintf("DATETIME('now', '-%d %s')", l, unit)
 	}
 	return fmt.Sprintf("DATE_SUB(NOW(), INTERVAL %d %s)", l, unit)
 }
 
 // CreateUser creates a new user in the database from the given User, UPDATING it in the process with the user's ID.
 func (db *datastore) CreateUser(cfg *config.Config, u *User, collectionTitle string) error {
 	if db.PostIDExists(u.Username) {
 		return impart.HTTPError{http.StatusConflict, "Invalid collection name."}
 	}
 
 	// New users get a `users` and `collections` row.
 	t, err := db.Begin()
 	if err != nil {
 		return err
 	}
 
 	// 1. Add to `users` table
 	// NOTE: Assumes User's Password is already hashed!
 	res, err := t.Exec("INSERT INTO users (username, password, email) VALUES (?, ?, ?)", u.Username, u.HashedPass, u.Email)
 	if err != nil {
 		t.Rollback()
 		if db.isDuplicateKeyErr(err) {
 			return impart.HTTPError{http.StatusConflict, "Username is already taken."}
 		}
 
 		log.Error("Rolling back users INSERT: %v\n", err)
 		return err
 	}
 	u.ID, err = res.LastInsertId()
 	if err != nil {
 		t.Rollback()
 		log.Error("Rolling back after LastInsertId: %v\n", err)
 		return err
 	}
 
 	// 2. Create user's Collection
 	if collectionTitle == "" {
 		collectionTitle = u.Username
 	}
 	res, err = t.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", u.Username, collectionTitle, "", defaultVisibility(cfg), u.ID, 0)
 	if err != nil {
 		t.Rollback()
 		if db.isDuplicateKeyErr(err) {
 			return impart.HTTPError{http.StatusConflict, "Username is already taken."}
 		}
 		log.Error("Rolling back collections INSERT: %v\n", err)
 		return err
 	}
 
 	db.RemoveCollectionRedirect(t, u.Username)
 
 	err = t.Commit()
 	if err != nil {
 		t.Rollback()
 		log.Error("Rolling back after Commit(): %v\n", err)
 		return err
 	}
 
 	return nil
 }
 
 // FIXME: We're returning errors inconsistently in this file. Do we use Errorf
 // for returned value, or impart?
 func (db *datastore) UpdateUserEmail(keys *key.Keychain, userID int64, email string) error {
 	encEmail, err := data.Encrypt(keys.EmailKey, email)
 	if err != nil {
 		return fmt.Errorf("Couldn't encrypt email %s: %s\n", email, err)
 	}
 
 	return db.UpdateEncryptedUserEmail(userID, encEmail)
 }
 
 func (db *datastore) UpdateEncryptedUserEmail(userID int64, encEmail []byte) error {
 	_, err := db.Exec("UPDATE users SET email = ? WHERE id = ?", encEmail, userID)
 	if err != nil {
 		return fmt.Errorf("Unable to update user email: %s", err)
 	}
 
 	return nil
 }
 
 func (db *datastore) CreateCollectionFromToken(cfg *config.Config, alias, title, accessToken string) (*Collection, error) {
 	userID := db.GetUserID(accessToken)
 	if userID == -1 {
 		return nil, ErrBadAccessToken
 	}
 
 	return db.CreateCollection(cfg, alias, title, userID)
 }
 
 func (db *datastore) GetUserCollectionCount(userID int64) (uint64, error) {
 	var collCount uint64
 	err := db.QueryRow("SELECT COUNT(*) FROM collections WHERE owner_id = ?", userID).Scan(&collCount)
 	switch {
 	case err == sql.ErrNoRows:
 		return 0, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user from database."}
 	case err != nil:
 		log.Error("Couldn't get collections count for user %d: %v", userID, err)
 		return 0, err
 	}
 
 	return collCount, nil
 }
 
 func (db *datastore) CreateCollection(cfg *config.Config, alias, title string, userID int64) (*Collection, error) {
 	if db.PostIDExists(alias) {
 		return nil, impart.HTTPError{http.StatusConflict, "Invalid collection name."}
 	}
 
 	// All good, so create new collection
 	res, err := db.Exec("INSERT INTO collections (alias, title, description, privacy, owner_id, view_count) VALUES (?, ?, ?, ?, ?, ?)", alias, title, "", defaultVisibility(cfg), userID, 0)
 	if err != nil {
 		if db.isDuplicateKeyErr(err) {
 			return nil, impart.HTTPError{http.StatusConflict, "Collection already exists."}
 		}
 		log.Error("Couldn't add to collections: %v\n", err)
 		return nil, err
 	}
 
 	c := &Collection{
 		Alias:       alias,
 		Title:       title,
 		OwnerID:     userID,
 		PublicOwner: false,
 		Public:      defaultVisibility(cfg) == CollPublic,
 	}
 
 	c.ID, err = res.LastInsertId()
 	if err != nil {
 		log.Error("Couldn't get collection LastInsertId: %v\n", err)
 	}
 
 	return c, nil
 }
 
 func (db *datastore) GetUserByID(id int64) (*User, error) {
 	u := &User{ID: id}
 
 	err := db.QueryRow("SELECT username, password, email, created, status FROM users WHERE id = ?", id).Scan(&u.Username, &u.HashedPass, &u.Email, &u.Created, &u.Status)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, ErrUserNotFound
 	case err != nil:
 		log.Error("Couldn't SELECT user password: %v", err)
 		return nil, err
 	}
 
 	return u, nil
 }
 
 // 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 silenced: %v", ErrUserNotFound)
 	case err != nil:
 		log.Error("Couldn't SELECT user status: %v", err)
 		return false, fmt.Errorf("is user silenced: %v", err)
 	}
 
 	return u.IsSilenced(), nil
 }
 
 // DoesUserNeedAuth returns true if the user hasn't provided any methods for
 // authenticating with the account, such a passphrase or email address.
 // Any errors are reported to admin and silently quashed, returning false as the
 // result.
 func (db *datastore) DoesUserNeedAuth(id int64) bool {
 	var pass, email []byte
 
 	// Find out if user has an email set first
 	err := db.QueryRow("SELECT password, email FROM users WHERE id = ?", id).Scan(&pass, &email)
 	switch {
 	case err == sql.ErrNoRows:
 		// ERROR. Don't give false positives on needing auth methods
 		return false
 	case err != nil:
 		// ERROR. Don't give false positives on needing auth methods
 		log.Error("Couldn't SELECT user %d from users: %v", id, err)
 		return false
 	}
 	// User doesn't need auth if there's an email
 	return len(email) == 0 && len(pass) == 0
 }
 
 func (db *datastore) IsUserPassSet(id int64) (bool, error) {
 	var pass []byte
 	err := db.QueryRow("SELECT password FROM users WHERE id = ?", id).Scan(&pass)
 	switch {
 	case err == sql.ErrNoRows:
 		return false, nil
 	case err != nil:
 		log.Error("Couldn't SELECT user %d from users: %v", id, err)
 		return false, err
 	}
 
 	return len(pass) > 0, nil
 }
 
 func (db *datastore) GetUserForAuth(username string) (*User, error) {
 	u := &User{Username: username}
 
 	err := db.QueryRow("SELECT id, password, email, created, status FROM users WHERE username = ?", username).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status)
 	switch {
 	case err == sql.ErrNoRows:
 		// Check if they've entered the wrong, unnormalized username
 		username = getSlug(username, "")
 		if username != u.Username {
 			err = db.QueryRow("SELECT id FROM users WHERE username = ? LIMIT 1", username).Scan(&u.ID)
 			if err == nil {
 				return db.GetUserForAuth(username)
 			}
 		}
 		return nil, ErrUserNotFound
 	case err != nil:
 		log.Error("Couldn't SELECT user password: %v", err)
 		return nil, err
 	}
 
 	return u, nil
 }
 
 func (db *datastore) GetUserForAuthByID(userID int64) (*User, error) {
 	u := &User{ID: userID}
 
 	err := db.QueryRow("SELECT id, password, email, created, status FROM users WHERE id = ?", u.ID).Scan(&u.ID, &u.HashedPass, &u.Email, &u.Created, &u.Status)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, ErrUserNotFound
 	case err != nil:
 		log.Error("Couldn't SELECT userForAuthByID: %v", err)
 		return nil, err
 	}
 
 	return u, nil
 }
 
 func (db *datastore) GetUserNameFromToken(accessToken string) (string, error) {
 	t := auth.GetToken(accessToken)
 	if len(t) == 0 {
 		return "", ErrNoAccessToken
 	}
 
 	var oneTime bool
 	var username string
 	err := db.QueryRow("SELECT username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&username, &oneTime)
 	switch {
 	case err == sql.ErrNoRows:
 		return "", ErrBadAccessToken
 	case err != nil:
 		return "", ErrInternalGeneral
 	}
 
 	// Delete token if it was one-time
 	if oneTime {
 		db.DeleteToken(t[:])
 	}
 
 	return username, nil
 }
 
 func (db *datastore) GetUserDataFromToken(accessToken string) (int64, string, error) {
 	t := auth.GetToken(accessToken)
 	if len(t) == 0 {
 		return 0, "", ErrNoAccessToken
 	}
 
 	var userID int64
 	var oneTime bool
 	var username string
 	err := db.QueryRow("SELECT user_id, username, one_time FROM accesstokens LEFT JOIN users ON user_id = id WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&userID, &username, &oneTime)
 	switch {
 	case err == sql.ErrNoRows:
 		return 0, "", ErrBadAccessToken
 	case err != nil:
 		return 0, "", ErrInternalGeneral
 	}
 
 	// Delete token if it was one-time
 	if oneTime {
 		db.DeleteToken(t[:])
 	}
 
 	return userID, username, nil
 }
 
 func (db *datastore) GetAPIUser(header string) (*User, error) {
 	uID := db.GetUserID(header)
 	if uID == -1 {
 		return nil, fmt.Errorf(ErrUserNotFound.Error())
 	}
 	return db.GetUserByID(uID)
 }
 
 // GetUserID takes a hexadecimal accessToken, parses it into its binary
 // representation, and gets any user ID associated with the token. If no user
 // is associated, -1 is returned.
 func (db *datastore) GetUserID(accessToken string) int64 {
 	i, _ := db.GetUserIDPrivilege(accessToken)
 	return i
 }
 
 func (db *datastore) GetUserIDPrivilege(accessToken string) (userID int64, sudo bool) {
 	t := auth.GetToken(accessToken)
 	if len(t) == 0 {
 		return -1, false
 	}
 
 	var oneTime bool
 	err := db.QueryRow("SELECT user_id, sudo, one_time FROM accesstokens WHERE token LIKE ? AND (expires IS NULL OR expires > "+db.now()+")", t).Scan(&userID, &sudo, &oneTime)
 	switch {
 	case err == sql.ErrNoRows:
 		return -1, false
 	case err != nil:
 		return -1, false
 	}
 
 	// Delete token if it was one-time
 	if oneTime {
 		db.DeleteToken(t[:])
 	}
 
 	return
 }
 
 func (db *datastore) DeleteToken(accessToken []byte) error {
 	res, err := db.Exec("DELETE FROM accesstokens WHERE token LIKE ?", accessToken)
 	if err != nil {
 		return err
 	}
 	rowsAffected, _ := res.RowsAffected()
 	if rowsAffected == 0 {
 		return impart.HTTPError{http.StatusNotFound, "Token is invalid or doesn't exist"}
 	}
 	return nil
 }
 
 // FetchLastAccessToken creates a new non-expiring, valid access token for the given
 // userID.
 func (db *datastore) FetchLastAccessToken(userID int64) string {
 	var t []byte
 	err := db.QueryRow("SELECT token FROM accesstokens WHERE user_id = ? AND (expires IS NULL OR expires > "+db.now()+") ORDER BY created DESC LIMIT 1", userID).Scan(&t)
 	switch {
 	case err == sql.ErrNoRows:
 		return ""
 	case err != nil:
 		log.Error("Failed selecting from accesstoken: %v", err)
 		return ""
 	}
 
 	u, err := uuid.Parse(t)
 	if err != nil {
 		return ""
 	}
 	return u.String()
 }
 
 // GetAccessToken creates a new non-expiring, valid access token for the given
 // userID.
 func (db *datastore) GetAccessToken(userID int64) (string, error) {
 	return db.GetTemporaryOneTimeAccessToken(userID, 0, false)
 }
 
 // GetTemporaryAccessToken creates a new valid access token for the given
 // userID that remains valid for the given time in seconds. If validSecs is 0,
 // the access token doesn't automatically expire.
 func (db *datastore) GetTemporaryAccessToken(userID int64, validSecs int) (string, error) {
 	return db.GetTemporaryOneTimeAccessToken(userID, validSecs, false)
 }
 
 // GetTemporaryOneTimeAccessToken creates a new valid access token for the given
 // userID that remains valid for the given time in seconds and can only be used
 // once if oneTime is true. If validSecs is 0, the access token doesn't
 // automatically expire.
 func (db *datastore) GetTemporaryOneTimeAccessToken(userID int64, validSecs int, oneTime bool) (string, error) {
 	u, err := uuid.NewV4()
 	if err != nil {
 		log.Error("Unable to generate token: %v", err)
 		return "", err
 	}
 
 	// Insert UUID to `accesstokens`
 	binTok := u[:]
 
 	expirationVal := "NULL"
 	if validSecs > 0 {
 		expirationVal = fmt.Sprintf("DATE_ADD("+db.now()+", INTERVAL %d SECOND)", validSecs)
 	}
 
 	_, err = db.Exec("INSERT INTO accesstokens (token, user_id, one_time, expires) VALUES (?, ?, ?, "+expirationVal+")", string(binTok), userID, oneTime)
 	if err != nil {
 		log.Error("Couldn't INSERT accesstoken: %v", err)
 		return "", err
 	}
 
 	return u.String(), nil
 }
 
 func (db *datastore) CreateOwnedPost(post *SubmittedPost, accessToken, collAlias, hostName string) (*PublicPost, error) {
 	var userID, collID int64 = -1, -1
 	var coll *Collection
 	var err error
 	if accessToken != "" {
 		userID = db.GetUserID(accessToken)
 		if userID == -1 {
 			return nil, ErrBadAccessToken
 		}
 		if collAlias != "" {
 			coll, err = db.GetCollection(collAlias)
 			if err != nil {
 				return nil, err
 			}
 			coll.hostName = hostName
 			if coll.OwnerID != userID {
 				return nil, ErrForbiddenCollection
 			}
 			collID = coll.ID
 		}
 	}
 
 	rp := &PublicPost{}
 	rp.Post, err = db.CreatePost(userID, collID, post)
 	if err != nil {
 		return rp, err
 	}
 	if coll != nil {
 		coll.ForPublic()
 		rp.Collection = &CollectionObj{Collection: *coll}
 	}
 	return rp, nil
 }
 
 func (db *datastore) CreatePost(userID, collID int64, post *SubmittedPost) (*Post, error) {
 	idLen := postIDLen
 	friendlyID := store.GenerateFriendlyRandomString(idLen)
 
 	// Handle appearance / font face
 	appearance := post.Font
 	if !post.isFontValid() {
 		appearance = "norm"
 	}
 
 	var err error
 	ownerID := sql.NullInt64{
 		Valid: false,
 	}
 	ownerCollID := sql.NullInt64{
 		Valid: false,
 	}
 	slug := sql.NullString{"", false}
 
 	// If an alias was supplied, we'll add this to the collection as well.
 	if userID > 0 {
 		ownerID.Int64 = userID
 		ownerID.Valid = true
 		if collID > 0 {
 			ownerCollID.Int64 = collID
 			ownerCollID.Valid = true
 			var slugVal string
 			if post.Slug != nil && *post.Slug != "" {
 				slugVal = *post.Slug
 			} else {
 				if post.Title != nil && *post.Title != "" {
 					slugVal = getSlug(*post.Title, post.Language.String)
 					if slugVal == "" {
 						slugVal = getSlug(*post.Content, post.Language.String)
 					}
 				} else {
 					slugVal = getSlug(*post.Content, post.Language.String)
 				}
 			}
 			if slugVal == "" {
 				slugVal = friendlyID
 			}
 			slug = sql.NullString{slugVal, true}
 		}
 	}
 
 	created := time.Now()
 	if db.driverName == driverSQLite {
 		// SQLite stores datetimes in UTC, so convert time.Now() to it here
 		created = created.UTC()
 	}
 	if post.Created != nil {
 		created, err = time.Parse("2006-01-02T15:04:05Z", *post.Created)
 		if err != nil {
 			log.Error("Unable to parse Created time '%s': %v", *post.Created, err)
 			created = time.Now()
 			if db.driverName == driverSQLite {
 				// SQLite stores datetimes in UTC, so convert time.Now() to it here
 				created = created.UTC()
 			}
 		}
 	}
 
 	stmt, err := db.Prepare("INSERT INTO posts (id, slug, title, content, text_appearance, language, rtl, privacy, owner_id, collection_id, created, updated, view_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, " + db.now() + ", ?)")
 	if err != nil {
 		return nil, err
 	}
 	defer stmt.Close()
 	_, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0)
 	if err != nil {
 		if db.isDuplicateKeyErr(err) {
 			// Duplicate entry error; try a new slug
 			// TODO: make this a little more robust
 			slug = sql.NullString{id.GenSafeUniqueSlug(slug.String), true}
 			_, err = stmt.Exec(friendlyID, slug, post.Title, post.Content, appearance, post.Language, post.IsRTL, 0, ownerID, ownerCollID, created, 0)
 			if err != nil {
 				return nil, handleFailedPostInsert(fmt.Errorf("Retried slug generation, still failed: %v", err))
 			}
 		} else {
 			return nil, handleFailedPostInsert(err)
 		}
 	}
 
 	// TODO: return Created field in proper format
 	return &Post{
 		ID:           friendlyID,
 		Slug:         null.NewString(slug.String, slug.Valid),
 		Font:         appearance,
 		Language:     zero.NewString(post.Language.String, post.Language.Valid),
 		RTL:          zero.NewBool(post.IsRTL.Bool, post.IsRTL.Valid),
 		OwnerID:      null.NewInt(userID, true),
 		CollectionID: null.NewInt(userID, true),
 		Created:      created.Truncate(time.Second).UTC(),
 		Updated:      time.Now().Truncate(time.Second).UTC(),
 		Title:        zero.NewString(*(post.Title), true),
 		Content:      *(post.Content),
 	}, nil
 }
 
 // UpdateOwnedPost updates an existing post with only the given fields in the
 // supplied AuthenticatedPost.
 func (db *datastore) UpdateOwnedPost(post *AuthenticatedPost, userID int64) error {
 	params := []interface{}{}
 	var queryUpdates, sep, authCondition string
 	if post.Slug != nil && *post.Slug != "" {
 		queryUpdates += sep + "slug = ?"
 		sep = ", "
 		params = append(params, getSlug(*post.Slug, ""))
 	}
 	if post.Content != nil {
 		queryUpdates += sep + "content = ?"
 		sep = ", "
 		params = append(params, post.Content)
 	}
 	if post.Title != nil {
 		queryUpdates += sep + "title = ?"
 		sep = ", "
 		params = append(params, post.Title)
 	}
 	if post.Language.Valid {
 		queryUpdates += sep + "language = ?"
 		sep = ", "
 		params = append(params, post.Language.String)
 	}
 	if post.IsRTL.Valid {
 		queryUpdates += sep + "rtl = ?"
 		sep = ", "
 		params = append(params, post.IsRTL.Bool)
 	}
 	if post.Font != "" {
 		queryUpdates += sep + "text_appearance = ?"
 		sep = ", "
 		params = append(params, post.Font)
 	}
 	if post.Created != nil {
 		createTime, err := time.Parse(postMetaDateFormat, *post.Created)
 		if err != nil {
 			log.Error("Unable to parse Created date: %v", err)
 			return fmt.Errorf("That's the incorrect format for Created date.")
 		}
 		queryUpdates += sep + "created = ?"
 		sep = ", "
 		params = append(params, createTime)
 	}
 
 	// WHERE parameters...
 	// id = ?
 	params = append(params, post.ID)
 	// AND owner_id = ?
 	authCondition = "(owner_id = ?)"
 	params = append(params, userID)
 
 	if queryUpdates == "" {
 		return ErrPostNoUpdatableVals
 	}
 
 	queryUpdates += sep + "updated = " + db.now()
 
 	res, err := db.Exec("UPDATE posts SET "+queryUpdates+" WHERE id = ? AND "+authCondition, params...)
 	if err != nil {
 		log.Error("Unable to update owned post: %v", err)
 		return err
 	}
 
 	rowsAffected, _ := res.RowsAffected()
 	if rowsAffected == 0 {
 		// Show the correct error message if nothing was updated
 		var dummy int
 		err := db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND "+authCondition, post.ID, params[len(params)-1]).Scan(&dummy)
 		switch {
 		case err == sql.ErrNoRows:
 			return ErrUnauthorizedEditPost
 		case err != nil:
 			log.Error("Failed selecting from posts: %v", err)
 		}
 		return nil
 	}
 
 	return nil
 }
 
 func (db *datastore) GetCollectionBy(condition string, value interface{}) (*Collection, error) {
 	c := &Collection{}
 
 	// FIXME: change Collection to reflect database values. Add helper functions to get actual values
 	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, &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()
 
 	c.db = db
 
 	return c, nil
 }
 
 func (db *datastore) GetCollection(alias string) (*Collection, error) {
 	return db.GetCollectionBy("alias = ?", alias)
 }
 
 func (db *datastore) GetCollectionForPad(alias string) (*Collection, error) {
 	c := &Collection{Alias: alias}
 
 	row := db.QueryRow("SELECT id, alias, title, description, privacy FROM collections WHERE alias = ?", alias)
 
 	err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &c.Visibility)
 	switch {
 	case err == sql.ErrNoRows:
 		return c, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
 	case err != nil:
 		log.Error("Failed selecting from collections: %v", err)
 		return c, ErrInternalGeneral
 	}
 	c.Public = c.IsPublic()
 
 	return c, nil
 }
 
 func (db *datastore) GetCollectionByID(id int64) (*Collection, error) {
 	return db.GetCollectionBy("id = ?", id)
 }
 
 func (db *datastore) GetCollectionFromDomain(host string) (*Collection, error) {
 	return db.GetCollectionBy("host = ?", host)
 }
 
 func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) error {
 	q := query.NewUpdate().
 		SetStringPtr(c.Title, "title").
 		SetStringPtr(c.Description, "description").
 		SetNullString(c.StyleSheet, "style_sheet").
 		SetNullString(c.Script, "script").
 		SetNullString(c.Signature, "post_signature")
 
 	if c.Format != nil {
 		cf := &CollectionFormat{Format: c.Format.String}
 		if cf.Valid() {
 			q.SetNullString(c.Format, "format")
 		}
 	}
 
 	var updatePass bool
 	if c.Visibility != nil && (collVisibility(*c.Visibility)&CollProtected == 0 || c.Pass != "") {
 		q.SetIntPtr(c.Visibility, "privacy")
 		if c.Pass != "" {
 			updatePass = true
 		}
 	}
 
 	// WHERE values
 	q.Where("alias = ? AND owner_id = ?", alias, c.OwnerID)
 
 	if q.Updates == "" {
 		return ErrPostNoUpdatableVals
 	}
 
 	// Find any current domain
 	var collID int64
 	var rowsAffected int64
 	var changed bool
 	var res sql.Result
 	err := db.QueryRow("SELECT id FROM collections WHERE alias = ?", alias).Scan(&collID)
 	if err != nil {
 		log.Error("Failed selecting from collections: %v. Some things won't work.", err)
 	}
 
 	// Update MathJax value
 	if c.MathJax {
 		if db.driverName == driverSQLite {
 			_, err = db.Exec("INSERT OR REPLACE INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", collID, "render_mathjax", "1")
 		} else {
 			_, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) "+db.upsert("collection_id", "attribute")+" value = ?", collID, "render_mathjax", "1", "1")
 		}
 		if err != nil {
 			log.Error("Unable to insert render_mathjax value: %v", err)
 			return err
 		}
 	} else {
 		_, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "render_mathjax")
 		if err != nil {
 			log.Error("Unable to delete render_mathjax value: %v", err)
 			return err
 		}
 	}
 
 	// Update Monetization value
 	if c.Monetization != nil {
 		skipUpdate := false
 		if *c.Monetization != "" {
 			// Strip away any excess spaces
 			trimmed := strings.TrimSpace(*c.Monetization)
 			// Only update value when it starts with "$", per spec: https://paymentpointers.org
 			if strings.HasPrefix(trimmed, "$") {
 				c.Monetization = &trimmed
 			} else {
 				// Value appears invalid, so don't update
 				skipUpdate = true
 			}
 		}
 		if !skipUpdate {
 			_, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?", collID, "monetization_pointer", *c.Monetization, *c.Monetization)
 			if err != nil {
 				log.Error("Unable to insert monetization_pointer value: %v", err)
 				return err
 			}
 		}
 	}
 
 	// Update rest of the collection data
 	res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
 	if err != nil {
 		log.Error("Unable to update collection: %v", err)
 		return err
 	}
 
 	rowsAffected, _ = res.RowsAffected()
 	if !changed || rowsAffected == 0 {
 		// Show the correct error message if nothing was updated
 		var dummy int
 		err := db.QueryRow("SELECT 1 FROM collections WHERE alias = ? AND owner_id = ?", alias, c.OwnerID).Scan(&dummy)
 		switch {
 		case err == sql.ErrNoRows:
 			return ErrUnauthorizedEditPost
 		case err != nil:
 			log.Error("Failed selecting from collections: %v", err)
 		}
 		if !updatePass {
 			return nil
 		}
 	}
 
 	if updatePass {
 		hashedPass, err := auth.HashPass([]byte(c.Pass))
 		if err != nil {
 			log.Error("Unable to create hash: %s", err)
 			return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
 		}
 		if db.driverName == driverSQLite {
 			_, err = db.Exec("INSERT OR REPLACE INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?)", alias, hashedPass)
 		} else {
 			_, err = db.Exec("INSERT INTO collectionpasswords (collection_id, password) VALUES ((SELECT id FROM collections WHERE alias = ?), ?) "+db.upsert("collection_id")+" password = ?", alias, hashedPass, hashedPass)
 		}
 		if err != nil {
 			return err
 		}
 	}
 
 	return nil
 }
 
 const postCols = "id, slug, text_appearance, language, rtl, privacy, owner_id, collection_id, pinned_position, created, updated, view_count, title, content"
 
 // getEditablePost returns a PublicPost with the given ID only if the given
 // edit token is valid for the post.
 func (db *datastore) GetEditablePost(id, editToken string) (*PublicPost, error) {
 	// FIXME: code duplicated from getPost()
 	// TODO: add slight logic difference to getPost / one func
 	var ownerName sql.NullString
 	p := &Post{}
 
 	row := db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE id = ? LIMIT 1", id)
 	err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, ErrPostNotFound
 	case err != nil:
 		log.Error("Failed selecting from collections: %v", err)
 		return nil, err
 	}
 
 	if p.Content == "" && p.Title.String == "" {
 		return nil, ErrPostUnpublished
 	}
 
 	res := p.processPost()
 	if ownerName.Valid {
 		res.Owner = &PublicUser{Username: ownerName.String}
 	}
 
 	return &res, nil
 }
 
 func (db *datastore) PostIDExists(id string) bool {
 	var dummy bool
 	err := db.QueryRow("SELECT 1 FROM posts WHERE id = ?", id).Scan(&dummy)
 	return err == nil && dummy
 }
 
 // GetPost gets a public-facing post object from the database. If collectionID
 // is > 0, the post will be retrieved by slug and collection ID, rather than
 // post ID.
 // TODO: break this into two functions:
 //   - GetPost(id string)
 //   - GetCollectionPost(slug string, collectionID int64)
 func (db *datastore) GetPost(id string, collectionID int64) (*PublicPost, error) {
 	var ownerName sql.NullString
 	p := &Post{}
 
 	var row *sql.Row
 	var where string
 	params := []interface{}{id}
 	if collectionID > 0 {
 		where = "slug = ? AND collection_id = ?"
 		params = append(params, collectionID)
 	} else {
 		where = "id = ?"
 	}
 	row = db.QueryRow("SELECT "+postCols+", (SELECT username FROM users WHERE users.id = posts.owner_id) AS username FROM posts WHERE "+where+" LIMIT 1", params...)
 	err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content, &ownerName)
 	switch {
 	case err == sql.ErrNoRows:
 		if collectionID > 0 {
 			return nil, ErrCollectionPageNotFound
 		}
 		return nil, ErrPostNotFound
 	case err != nil:
 		log.Error("Failed selecting from collections: %v", err)
 		return nil, err
 	}
 
 	if p.Content == "" && p.Title.String == "" {
 		return nil, ErrPostUnpublished
 	}
 
 	res := p.processPost()
 	if ownerName.Valid {
 		res.Owner = &PublicUser{Username: ownerName.String}
 	}
 
 	return &res, nil
 }
 
 // TODO: don't duplicate getPost() functionality
 func (db *datastore) GetOwnedPost(id string, ownerID int64) (*PublicPost, error) {
 	p := &Post{}
 
 	var row *sql.Row
 	where := "id = ? AND owner_id = ?"
 	params := []interface{}{id, ownerID}
 	row = db.QueryRow("SELECT "+postCols+" FROM posts WHERE "+where+" LIMIT 1", params...)
 	err := row.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, ErrPostNotFound
 	case err != nil:
 		log.Error("Failed selecting from collections: %v", err)
 		return nil, err
 	}
 
 	if p.Content == "" && p.Title.String == "" {
 		return nil, ErrPostUnpublished
 	}
 
 	res := p.processPost()
 
 	return &res, nil
 }
 
 func (db *datastore) GetPostProperty(id string, collectionID int64, property string) (interface{}, error) {
 	propSelects := map[string]string{
 		"views": "view_count AS views",
 	}
 	selectQuery, ok := propSelects[property]
 	if !ok {
 		return nil, impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid property: %s.", property)}
 	}
 
 	var res interface{}
 	var row *sql.Row
 	if collectionID != 0 {
 		row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE slug = ? AND collection_id = ? LIMIT 1", id, collectionID)
 	} else {
 		row = db.QueryRow("SELECT "+selectQuery+" FROM posts WHERE id = ? LIMIT 1", id)
 	}
 	err := row.Scan(&res)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, impart.HTTPError{http.StatusNotFound, "Post not found."}
 	case err != nil:
 		log.Error("Failed selecting post: %v", err)
 		return nil, err
 	}
 
 	return res, nil
 }
 
 // GetPostsCount modifies the CollectionObj to include the correct number of
 // standard (non-pinned) posts. It will return future posts if `includeFuture`
 // is true.
 func (db *datastore) GetPostsCount(c *CollectionObj, includeFuture bool) {
 	var count int64
 	timeCondition := ""
 	if !includeFuture {
 		timeCondition = "AND created <= " + db.now()
 	}
 	err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE collection_id = ? AND pinned_position IS NULL "+timeCondition, c.ID).Scan(&count)
 	switch {
 	case err == sql.ErrNoRows:
 		c.TotalPosts = 0
 	case err != nil:
 		log.Error("Failed selecting from collections: %v", err)
 		c.TotalPosts = 0
 	}
 
 	c.TotalPosts = int(count)
 }
 
 // GetPosts retrieves all posts for the given Collection.
 // It will return future posts if `includeFuture` is true.
 // It will include only standard (non-pinned) posts unless `includePinned` is true.
 // TODO: change includeFuture to isOwner, since that's how it's used
 func (db *datastore) GetPosts(cfg *config.Config, c *Collection, page int, includeFuture, forceRecentFirst, includePinned bool) (*[]PublicPost, error) {
 	collID := c.ID
 
 	cf := c.NewFormat()
 	order := "DESC"
 	if cf.Ascending() && !forceRecentFirst {
 		order = "ASC"
 	}
 
 	pagePosts := cf.PostsPerPage()
 	start := page*pagePosts - pagePosts
 	if page == 0 {
 		start = 0
 		pagePosts = 1000
 	}
 
 	limitStr := ""
 	if page > 0 {
 		limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts)
 	}
 	timeCondition := ""
 	if !includeFuture {
 		timeCondition = "AND created <= " + db.now()
 	}
 	pinnedCondition := ""
 	if !includePinned {
 		pinnedCondition = "AND pinned_position IS NULL"
 	}
 	rows, err := db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? "+pinnedCondition+" "+timeCondition+" ORDER BY created "+order+limitStr, collID)
 	if err != nil {
 		log.Error("Failed selecting from posts: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."}
 	}
 	defer rows.Close()
 
 	// TODO: extract this common row scanning logic for queries using `postCols`
 	posts := []PublicPost{}
 	for rows.Next() {
 		p := &Post{}
 		err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
 		if err != nil {
 			log.Error("Failed scanning row: %v", err)
 			break
 		}
 		p.extractData()
 		p.augmentContent(c)
 		p.formatContent(cfg, c, includeFuture)
 
 		posts = append(posts, p.processPost())
 	}
 	err = rows.Err()
 	if err != nil {
 		log.Error("Error after Next() on rows: %v", err)
 	}
 
 	return &posts, nil
 }
 
 // GetPostsTagged retrieves all posts on the given Collection that contain the
 // given tag.
 // It will return future posts if `includeFuture` is true.
 // TODO: change includeFuture to isOwner, since that's how it's used
 func (db *datastore) GetPostsTagged(cfg *config.Config, c *Collection, tag string, page int, includeFuture bool) (*[]PublicPost, error) {
 	collID := c.ID
 
 	cf := c.NewFormat()
 	order := "DESC"
 	if cf.Ascending() {
 		order = "ASC"
 	}
 
 	pagePosts := cf.PostsPerPage()
 	start := page*pagePosts - pagePosts
 	if page == 0 {
 		start = 0
 		pagePosts = 1000
 	}
 
 	limitStr := ""
 	if page > 0 {
 		limitStr = fmt.Sprintf(" LIMIT %d, %d", start, pagePosts)
 	}
 	timeCondition := ""
 	if !includeFuture {
 		timeCondition = "AND created <= " + db.now()
 	}
 
 	var rows *sql.Rows
 	var err error
 	if db.driverName == driverSQLite {
 		rows, err = db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) regexp ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, `.*#`+strings.ToLower(tag)+`\b.*`)
 	} else {
 		rows, err = db.Query("SELECT "+postCols+" FROM posts WHERE collection_id = ? AND LOWER(content) RLIKE ? "+timeCondition+" ORDER BY created "+order+limitStr, collID, "#"+strings.ToLower(tag)+"[[:>:]]")
 	}
 	if err != nil {
 		log.Error("Failed selecting from posts: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve collection posts."}
 	}
 	defer rows.Close()
 
 	// TODO: extract this common row scanning logic for queries using `postCols`
 	posts := []PublicPost{}
 	for rows.Next() {
 		p := &Post{}
 		err = rows.Scan(&p.ID, &p.Slug, &p.Font, &p.Language, &p.RTL, &p.Privacy, &p.OwnerID, &p.CollectionID, &p.PinnedPosition, &p.Created, &p.Updated, &p.ViewCount, &p.Title, &p.Content)
 		if err != nil {
 			log.Error("Failed scanning row: %v", err)
 			break
 		}
 		p.extractData()
 		p.augmentContent(c)
 		p.formatContent(cfg, c, includeFuture)
 
 		posts = append(posts, p.processPost())
 	}
 	err = rows.Err()
 	if err != nil {
 		log.Error("Error after Next() on rows: %v", err)
 	}
 
 	return &posts, nil
 }
 
 func (db *datastore) GetAPFollowers(c *Collection) (*[]RemoteUser, error) {
 	rows, err := db.Query("SELECT actor_id, inbox, shared_inbox FROM remotefollows f INNER JOIN remoteusers u ON f.remote_user_id = u.id WHERE collection_id = ?", c.ID)
 	if err != nil {
 		log.Error("Failed selecting from followers: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve followers."}
 	}
 	defer rows.Close()
 
 	followers := []RemoteUser{}
 	for rows.Next() {
 		f := RemoteUser{}
 		err = rows.Scan(&f.ActorID, &f.Inbox, &f.SharedInbox)
 		followers = append(followers, f)
 	}
 	return &followers, nil
 }
 
 // CanCollect returns whether or not the given user can add the given post to a
 // collection. This is true when a post is already owned by the user.
 // NOTE: this is currently only used to potentially add owned posts to a
 // collection. This has the SIDE EFFECT of also generating a slug for the post.
 // FIXME: make this side effect more explicit (or extract it)
 func (db *datastore) CanCollect(cpr *ClaimPostRequest, userID int64) bool {
 	var title, content string
 	var lang sql.NullString
 	err := db.QueryRow("SELECT title, content, language FROM posts WHERE id = ? AND owner_id = ?", cpr.ID, userID).Scan(&title, &content, &lang)
 	switch {
 	case err == sql.ErrNoRows:
 		return false
 	case err != nil:
 		log.Error("Failed on post CanCollect(%s, %d): %v", cpr.ID, userID, err)
 		return false
 	}
 
 	// Since we have the post content and the post is collectable, generate the
 	// post's slug now.
 	cpr.Slug = getSlugFromPost(title, content, lang.String)
 
 	return true
 }
 
 func (db *datastore) AttemptClaim(p *ClaimPostRequest, query string, params []interface{}, slugIdx int) (sql.Result, error) {
 	qRes, err := db.Exec(query, params...)
 	if err != nil {
 		if db.isDuplicateKeyErr(err) && slugIdx > -1 {
 			s := id.GenSafeUniqueSlug(p.Slug)
 			if s == p.Slug {
 				// Sanity check to prevent infinite recursion
 				return qRes, fmt.Errorf("GenSafeUniqueSlug generated nothing unique: %s", s)
 			}
 			p.Slug = s
 			params[slugIdx] = p.Slug
 			return db.AttemptClaim(p, query, params, slugIdx)
 		}
 		return qRes, fmt.Errorf("attemptClaim: %s", err)
 	}
 	return qRes, nil
 }
 
 func (db *datastore) DispersePosts(userID int64, postIDs []string) (*[]ClaimPostResult, error) {
 	postClaimReqs := map[string]bool{}
 	res := []ClaimPostResult{}
 	for i := range postIDs {
 		postID := postIDs[i]
 
 		r := ClaimPostResult{Code: 0, ErrorMessage: ""}
 
 		// Perform post validation
 		if postID == "" {
 			r.ErrorMessage = "Missing post ID. "
 		}
 		if _, ok := postClaimReqs[postID]; ok {
 			r.Code = 429
 			r.ErrorMessage = "You've already tried anonymizing this post."
 			r.ID = postID
 			res = append(res, r)
 			continue
 		}
 		postClaimReqs[postID] = true
 
 		var err error
 		// Get full post information to return
 		var fullPost *PublicPost
 		fullPost, err = db.GetPost(postID, 0)
 		if err != nil {
 			if err, ok := err.(impart.HTTPError); ok {
 				r.Code = err.Status
 				r.ErrorMessage = err.Message
 				r.ID = postID
 				res = append(res, r)
 				continue
 			} else {
 				log.Error("Error getting post in dispersePosts: %v", err)
 			}
 		}
 		if fullPost.OwnerID.Int64 != userID {
 			r.Code = http.StatusConflict
 			r.ErrorMessage = "Post is already owned by someone else."
 			r.ID = postID
 			res = append(res, r)
 			continue
 		}
 
 		var qRes sql.Result
 		var query string
 		var params []interface{}
 		// Do AND owner_id = ? for sanity.
 		// This should've been caught and returned with a good error message
 		// just above.
 		query = "UPDATE posts SET collection_id = NULL WHERE id = ? AND owner_id = ?"
 		params = []interface{}{postID, userID}
 		qRes, err = db.Exec(query, params...)
 		if err != nil {
 			r.Code = http.StatusInternalServerError
 			r.ErrorMessage = "A glitch happened on our end."
 			r.ID = postID
 			res = append(res, r)
 			log.Error("dispersePosts (post %s): %v", postID, err)
 			continue
 		}
 
 		// Post was successfully dispersed
 		r.Code = http.StatusOK
 		r.Post = fullPost
 
 		rowsAffected, _ := qRes.RowsAffected()
 		if rowsAffected == 0 {
 			// This was already claimed, but return 200
 			r.Code = http.StatusOK
 		}
 		res = append(res, r)
 	}
 
 	return &res, nil
 }
 
 func (db *datastore) ClaimPosts(cfg *config.Config, userID int64, collAlias string, posts *[]ClaimPostRequest) (*[]ClaimPostResult, error) {
 	postClaimReqs := map[string]bool{}
 	res := []ClaimPostResult{}
 	postCollAlias := collAlias
 	for i := range *posts {
 		p := (*posts)[i]
 		if &p == nil {
 			continue
 		}
 
 		r := ClaimPostResult{Code: 0, ErrorMessage: ""}
 
 		// Perform post validation
 		if p.ID == "" {
 			r.ErrorMessage = "Missing post ID `id`. "
 		}
 		if _, ok := postClaimReqs[p.ID]; ok {
 			r.Code = 429
 			r.ErrorMessage = "You've already tried claiming this post."
 			r.ID = p.ID
 			res = append(res, r)
 			continue
 		}
 		postClaimReqs[p.ID] = true
 
 		canCollect := db.CanCollect(&p, userID)
 		if !canCollect && p.Token == "" {
 			// TODO: ensure post isn't owned by anyone else when a valid modify
 			// token is given.
 			r.ErrorMessage += "Missing post Edit Token `token`."
 		}
 		if r.ErrorMessage != "" {
 			// Post validate failed
 			r.Code = http.StatusBadRequest
 			r.ID = p.ID
 			res = append(res, r)
 			continue
 		}
 
 		var err error
 		var qRes sql.Result
 		var query string
 		var params []interface{}
 		var slugIdx int = -1
 		var coll *Collection
 		if collAlias == "" {
 			// Posts are being claimed at /posts/claim, not
 			// /collections/{alias}/collect, so use given individual collection
 			// to associate post with.
 			postCollAlias = p.CollectionAlias
 		}
 		if postCollAlias != "" {
 			// Associate this post with a collection
 			if p.CreateCollection {
 				// This is a new collection
 				// TODO: consider removing this. This seriously complicates this
 				// method and adds another (unnecessary?) logic path.
 				coll, err = db.CreateCollection(cfg, postCollAlias, "", userID)
 				if err != nil {
 					if err, ok := err.(impart.HTTPError); ok {
 						r.Code = err.Status
 						r.ErrorMessage = err.Message
 					} else {
 						r.Code = http.StatusInternalServerError
 						r.ErrorMessage = "Unknown error occurred creating collection"
 					}
 					r.ID = p.ID
 					res = append(res, r)
 					continue
 				}
 			} else {
 				// Attempt to add to existing collection
 				coll, err = db.GetCollection(postCollAlias)
 				if err != nil {
 					if err, ok := err.(impart.HTTPError); ok {
 						if err.Status == http.StatusNotFound {
 							// Show obfuscated "forbidden" response, as if attempting to add to an
 							// unowned blog.
 							r.Code = ErrForbiddenCollection.Status
 							r.ErrorMessage = ErrForbiddenCollection.Message
 						} else {
 							r.Code = err.Status
 							r.ErrorMessage = err.Message
 						}
 					} else {
 						r.Code = http.StatusInternalServerError
 						r.ErrorMessage = "Unknown error occurred claiming post with collection"
 					}
 					r.ID = p.ID
 					res = append(res, r)
 					continue
 				}
 				if coll.OwnerID != userID {
 					r.Code = ErrForbiddenCollection.Status
 					r.ErrorMessage = ErrForbiddenCollection.Message
 					r.ID = p.ID
 					res = append(res, r)
 					continue
 				}
 			}
 			if p.Slug == "" {
 				p.Slug = p.ID
 			}
 			if canCollect {
 				// User already owns this post, so just add it to the given
 				// collection.
 				query = "UPDATE posts SET collection_id = ?, slug = ? WHERE id = ? AND owner_id = ?"
 				params = []interface{}{coll.ID, p.Slug, p.ID, userID}
 				slugIdx = 1
 			} else {
 				query = "UPDATE posts SET owner_id = ?, collection_id = ?, slug = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL"
 				params = []interface{}{userID, coll.ID, p.Slug, p.ID, p.Token}
 				slugIdx = 2
 			}
 		} else {
 			query = "UPDATE posts SET owner_id = ? WHERE id = ? AND modify_token = ? AND owner_id IS NULL"
 			params = []interface{}{userID, p.ID, p.Token}
 		}
 		qRes, err = db.AttemptClaim(&p, query, params, slugIdx)
 		if err != nil {
 			r.Code = http.StatusInternalServerError
 			r.ErrorMessage = "An unknown error occurred."
 			r.ID = p.ID
 			res = append(res, r)
 			log.Error("claimPosts (post %s): %v", p.ID, err)
 			continue
 		}
 
 		// Get full post information to return
 		var fullPost *PublicPost
 		if p.Token != "" {
 			fullPost, err = db.GetEditablePost(p.ID, p.Token)
 		} else {
 			fullPost, err = db.GetPost(p.ID, 0)
 		}
 		if err != nil {
 			if err, ok := err.(impart.HTTPError); ok {
 				r.Code = err.Status
 				r.ErrorMessage = err.Message
 				r.ID = p.ID
 				res = append(res, r)
 				continue
 			}
 		}
 		if fullPost.OwnerID.Int64 != userID {
 			r.Code = http.StatusConflict
 			r.ErrorMessage = "Post is already owned by someone else."
 			r.ID = p.ID
 			res = append(res, r)
 			continue
 		}
 
 		// Post was successfully claimed
 		r.Code = http.StatusOK
 		r.Post = fullPost
 		if coll != nil {
 			r.Post.Collection = &CollectionObj{Collection: *coll}
 		}
 
 		rowsAffected, _ := qRes.RowsAffected()
 		if rowsAffected == 0 {
 			// This was already claimed, but return 200
 			r.Code = http.StatusOK
 		}
 		res = append(res, r)
 	}
 
 	return &res, nil
 }
 
 func (db *datastore) UpdatePostPinState(pinned bool, postID string, collID, ownerID, pos int64) error {
 	if pos <= 0 || pos > 20 {
 		pos = db.GetLastPinnedPostPos(collID) + 1
 		if pos == -1 {
 			pos = 1
 		}
 	}
 	var err error
 	if pinned {
 		_, err = db.Exec("UPDATE posts SET pinned_position = ? WHERE id = ?", pos, postID)
 	} else {
 		_, err = db.Exec("UPDATE posts SET pinned_position = NULL WHERE id = ?", postID)
 	}
 	if err != nil {
 		log.Error("Unable to update pinned post: %v", err)
 		return err
 	}
 	return nil
 }
 
 func (db *datastore) GetLastPinnedPostPos(collID int64) int64 {
 	var lastPos sql.NullInt64
 	err := db.QueryRow("SELECT MAX(pinned_position) FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL", collID).Scan(&lastPos)
 	switch {
 	case err == sql.ErrNoRows:
 		return -1
 	case err != nil:
 		log.Error("Failed selecting from posts: %v", err)
 		return -1
 	}
 	if !lastPos.Valid {
 		return -1
 	}
 	return lastPos.Int64
 }
 
 func (db *datastore) GetPinnedPosts(coll *CollectionObj, includeFuture bool) (*[]PublicPost, error) {
 	// FIXME: sqlite-backed instances don't include ellipsis on truncated titles
 	timeCondition := ""
 	if !includeFuture {
 		timeCondition = "AND created <= " + db.now()
 	}
 	rows, err := db.Query("SELECT id, slug, title, "+db.clip("content", 80)+", pinned_position FROM posts WHERE collection_id = ? AND pinned_position IS NOT NULL "+timeCondition+" ORDER BY pinned_position ASC", coll.ID)
 	if err != nil {
 		log.Error("Failed selecting pinned posts: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve pinned posts."}
 	}
 	defer rows.Close()
 
 	posts := []PublicPost{}
 	for rows.Next() {
 		p := &Post{}
 		err = rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Content, &p.PinnedPosition)
 		if err != nil {
 			log.Error("Failed scanning row: %v", err)
 			break
 		}
 		p.extractData()
 		p.augmentContent(&coll.Collection)
 
 		pp := p.processPost()
 		pp.Collection = coll
 		posts = append(posts, pp)
 	}
 	return &posts, nil
 }
 
 func (db *datastore) GetCollections(u *User, hostName string) (*[]Collection, error) {
 	rows, err := db.Query("SELECT id, alias, title, description, privacy, view_count FROM collections WHERE owner_id = ? ORDER BY id ASC", u.ID)
 	if err != nil {
 		log.Error("Failed selecting from collections: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user 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) GetPublishableCollections(u *User, hostName string) (*[]Collection, error) {
 	c, err := db.GetCollections(u, hostName)
 	if err != nil {
 		return nil, err
 	}
 
 	if len(*c) == 0 {
 		return nil, impart.HTTPError{http.StatusInternalServerError, "You don't seem to have any blogs; they might've moved to another account. Try logging out and logging into your other account."}
 	}
 	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{}
 
 	// User counts
 	colls, _ := db.GetUserCollectionCount(u.ID)
 	s.TotalCollections = colls
 
 	var articles, collPosts uint64
 	err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NULL", u.ID).Scan(&articles)
 	if err != nil && err != sql.ErrNoRows {
 		log.Error("Couldn't get articles count for user %d: %v", u.ID, err)
 	}
 	s.TotalArticles = articles
 
 	err = db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ? AND collection_id IS NOT NULL", u.ID).Scan(&collPosts)
 	if err != nil && err != sql.ErrNoRows {
 		log.Error("Couldn't get coll posts count for user %d: %v", u.ID, err)
 	}
 	s.CollectionPosts = collPosts
 
 	return s
 }
 
 func (db *datastore) GetTotalCollections() (collCount int64, err error) {
 	err = db.QueryRow(`
 	SELECT COUNT(*) 
 	FROM collections c
 	LEFT JOIN users u ON u.id = c.owner_id
 	WHERE u.status = 0`).Scan(&collCount)
 	if err != nil {
 		log.Error("Unable to fetch collections count: %v", err)
 	}
 	return
 }
 
 func (db *datastore) GetTotalPosts() (postCount int64, err error) {
 	err = db.QueryRow(`
 	SELECT COUNT(*)
 	FROM posts p
 	LEFT JOIN users u ON u.id = p.owner_id
 	WHERE u.status = 0`).Scan(&postCount)
 	if err != nil {
 		log.Error("Unable to fetch posts count: %v", err)
 	}
 	return
 }
 
 func (db *datastore) GetTopPosts(u *User, alias string) (*[]PublicPost, error) {
 	params := []interface{}{u.ID}
 	where := ""
 	if alias != "" {
 		where = " AND alias = ?"
 		params = append(params, alias)
 	}
 	rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON p.collection_id = c.id WHERE p.owner_id = ?"+where+" ORDER BY p.view_count DESC, created DESC LIMIT 25", params...)
 	if err != nil {
 		log.Error("Failed selecting from posts: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user top posts."}
 	}
 	defer rows.Close()
 
 	posts := []PublicPost{}
 	var gotErr bool
 	for rows.Next() {
 		p := Post{}
 		c := Collection{}
 		var alias, title, description sql.NullString
 		var views sql.NullInt64
 		err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &alias, &title, &description, &views)
 		if err != nil {
 			log.Error("Failed scanning User.getPosts() row: %v", err)
 			gotErr = true
 			break
 		}
 		p.extractData()
 		pubPost := p.processPost()
 
 		if alias.Valid && alias.String != "" {
 			c.Alias = alias.String
 			c.Title = title.String
 			c.Description = description.String
 			c.Views = views.Int64
 			pubPost.Collection = &CollectionObj{Collection: c}
 		}
 
 		posts = append(posts, pubPost)
 	}
 	err = rows.Err()
 	if err != nil {
 		log.Error("Error after Next() on rows: %v", err)
 	}
 
 	if gotErr && len(posts) == 0 {
 		// There were a lot of errors
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."}
 	}
 
 	return &posts, nil
 }
 
 func (db *datastore) GetAnonymousPosts(u *User) (*[]PublicPost, error) {
 	rows, err := db.Query("SELECT id, view_count, title, created, updated, content FROM posts WHERE owner_id = ? AND collection_id IS NULL ORDER BY created DESC", u.ID)
 	if err != nil {
 		log.Error("Failed selecting from posts: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user anonymous posts."}
 	}
 	defer rows.Close()
 
 	posts := []PublicPost{}
 	for rows.Next() {
 		p := Post{}
 		err = rows.Scan(&p.ID, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content)
 		if err != nil {
 			log.Error("Failed scanning row: %v", err)
 			break
 		}
 		p.extractData()
 
 		posts = append(posts, p.processPost())
 	}
 	err = rows.Err()
 	if err != nil {
 		log.Error("Error after Next() on rows: %v", err)
 	}
 
 	return &posts, nil
 }
 
 func (db *datastore) GetUserPosts(u *User) (*[]PublicPost, error) {
 	rows, err := db.Query("SELECT p.id, p.slug, p.view_count, p.title, p.created, p.updated, p.content, p.text_appearance, p.language, p.rtl, c.alias, c.title, c.description, c.view_count FROM posts p LEFT JOIN collections c ON collection_id = c.id WHERE p.owner_id = ? ORDER BY created ASC", u.ID)
 	if err != nil {
 		log.Error("Failed selecting from posts: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user posts."}
 	}
 	defer rows.Close()
 
 	posts := []PublicPost{}
 	var gotErr bool
 	for rows.Next() {
 		p := Post{}
 		c := Collection{}
 		var alias, title, description sql.NullString
 		var views sql.NullInt64
 		err = rows.Scan(&p.ID, &p.Slug, &p.ViewCount, &p.Title, &p.Created, &p.Updated, &p.Content, &p.Font, &p.Language, &p.RTL, &alias, &title, &description, &views)
 		if err != nil {
 			log.Error("Failed scanning User.getPosts() row: %v", err)
 			gotErr = true
 			break
 		}
 		p.extractData()
 		pubPost := p.processPost()
 
 		if alias.Valid && alias.String != "" {
 			c.Alias = alias.String
 			c.Title = title.String
 			c.Description = description.String
 			c.Views = views.Int64
 			pubPost.Collection = &CollectionObj{Collection: c}
 		}
 
 		posts = append(posts, pubPost)
 	}
 	err = rows.Err()
 	if err != nil {
 		log.Error("Error after Next() on rows: %v", err)
 	}
 
 	if gotErr && len(posts) == 0 {
 		// There were a lot of errors
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Unable to get data."}
 	}
 
 	return &posts, nil
 }
 
 func (db *datastore) GetUserPostsCount(userID int64) int64 {
 	var count int64
 	err := db.QueryRow("SELECT COUNT(*) FROM posts WHERE owner_id = ?", userID).Scan(&count)
 	switch {
 	case err == sql.ErrNoRows:
 		return 0
 	case err != nil:
 		log.Error("Failed selecting posts count for user %d: %v", userID, err)
 		return 0
 	}
 
 	return count
 }
 
 // ChangeSettings takes a User and applies the changes in the given
 // userSettings, MODIFYING THE USER with successful changes.
 func (db *datastore) ChangeSettings(app *App, u *User, s *userSettings) error {
 	var errPass error
 	q := query.NewUpdate()
 
 	// Update email if given
 	if s.Email != "" {
 		encEmail, err := data.Encrypt(app.keys.EmailKey, s.Email)
 		if err != nil {
 			log.Error("Couldn't encrypt email %s: %s\n", s.Email, err)
 			return impart.HTTPError{http.StatusInternalServerError, "Unable to encrypt email address."}
 		}
 		q.SetBytes(encEmail, "email")
 
 		// Update the email if something goes awry updating the password
 		defer func() {
 			if errPass != nil {
 				db.UpdateEncryptedUserEmail(u.ID, encEmail)
 			}
 		}()
 		u.Email = zero.StringFrom(s.Email)
 	}
 
 	// Update username if given
 	var newUsername string
 	if s.Username != "" {
 		var ie *impart.HTTPError
 		newUsername, ie = getValidUsername(app, s.Username, u.Username)
 		if ie != nil {
 			// Username is invalid
 			return *ie
 		}
 		if !author.IsValidUsername(app.cfg, newUsername) {
 			// Ensure the username is syntactically correct.
 			return impart.HTTPError{http.StatusPreconditionFailed, "Username isn't valid."}
 		}
 
 		t, err := db.Begin()
 		if err != nil {
 			log.Error("Couldn't start username change transaction: %v", err)
 			return err
 		}
 
 		_, err = t.Exec("UPDATE users SET username = ? WHERE id = ?", newUsername, u.ID)
 		if err != nil {
 			t.Rollback()
 			if db.isDuplicateKeyErr(err) {
 				return impart.HTTPError{http.StatusConflict, "Username is already taken."}
 			}
 			log.Error("Unable to update users table: %v", err)
 			return ErrInternalGeneral
 		}
 
 		_, err = t.Exec("UPDATE collections SET alias = ? WHERE alias = ? AND owner_id = ?", newUsername, u.Username, u.ID)
 		if err != nil {
 			t.Rollback()
 			if db.isDuplicateKeyErr(err) {
 				return impart.HTTPError{http.StatusConflict, "Username is already taken."}
 			}
 			log.Error("Unable to update collection: %v", err)
 			return ErrInternalGeneral
 		}
 
 		// Keep track of name changes for redirection
 		db.RemoveCollectionRedirect(t, newUsername)
 		_, err = t.Exec("UPDATE collectionredirects SET new_alias = ? WHERE new_alias = ?", newUsername, u.Username)
 		if err != nil {
 			log.Error("Unable to update collectionredirects: %v", err)
 		}
 		_, err = t.Exec("INSERT INTO collectionredirects (prev_alias, new_alias) VALUES (?, ?)", u.Username, newUsername)
 		if err != nil {
 			log.Error("Unable to add new collectionredirect: %v", err)
 		}
 
 		err = t.Commit()
 		if err != nil {
 			t.Rollback()
 			log.Error("Rolling back after Commit(): %v\n", err)
 			return err
 		}
 
 		u.Username = newUsername
 	}
 
 	// Update passphrase if given
 	if s.NewPass != "" {
 		// Check if user has already set a password
 		var err error
 		u.HasPass, err = db.IsUserPassSet(u.ID)
 		if err != nil {
 			errPass = impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data."}
 			return errPass
 		}
 
 		if u.HasPass {
 			// Check if currently-set password is correct
 			hashedPass := u.HashedPass
 			if len(hashedPass) == 0 {
 				authUser, err := db.GetUserForAuthByID(u.ID)
 				if err != nil {
 					errPass = err
 					return errPass
 				}
 				hashedPass = authUser.HashedPass
 			}
 			if !auth.Authenticated(hashedPass, []byte(s.OldPass)) {
 				errPass = impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
 				return errPass
 			}
 		}
 		hashedPass, err := auth.HashPass([]byte(s.NewPass))
 		if err != nil {
 			errPass = impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
 			return errPass
 		}
 		q.SetBytes(hashedPass, "password")
 	}
 
 	// WHERE values
 	q.Append(u.ID)
 
 	if q.Updates == "" {
 		if s.Username == "" {
 			return ErrPostNoUpdatableVals
 		}
 
 		// Nothing to update except username. That was successful, so return now.
 		return nil
 	}
 
 	res, err := db.Exec("UPDATE users SET "+q.Updates+" WHERE id = ?", q.Params...)
 	if err != nil {
 		log.Error("Unable to update collection: %v", err)
 		return err
 	}
 
 	rowsAffected, _ := res.RowsAffected()
 	if rowsAffected == 0 {
 		// Show the correct error message if nothing was updated
 		var dummy int
 		err := db.QueryRow("SELECT 1 FROM users WHERE id = ?", u.ID).Scan(&dummy)
 		switch {
 		case err == sql.ErrNoRows:
 			return ErrUnauthorizedGeneral
 		case err != nil:
 			log.Error("Failed selecting from users: %v", err)
 		}
 		return nil
 	}
 
 	if s.NewPass != "" && !u.HasPass {
 		u.HasPass = true
 	}
 
 	return nil
 }
 
 func (db *datastore) ChangePassphrase(userID int64, sudo bool, curPass string, hashedPass []byte) error {
 	var dbPass []byte
 	err := db.QueryRow("SELECT password FROM users WHERE id = ?", userID).Scan(&dbPass)
 	switch {
 	case err == sql.ErrNoRows:
 		return ErrUserNotFound
 	case err != nil:
 		log.Error("Couldn't SELECT user password for change: %v", err)
 		return err
 	}
 
 	if !sudo && !auth.Authenticated(dbPass, []byte(curPass)) {
 		return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
 	}
 
 	_, err = db.Exec("UPDATE users SET password = ? WHERE id = ?", hashedPass, userID)
 	if err != nil {
 		log.Error("Could not update passphrase: %v", err)
 		return err
 	}
 
 	return nil
 }
 
 func (db *datastore) RemoveCollectionRedirect(t *sql.Tx, alias string) error {
 	_, err := t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ?", alias)
 	if err != nil {
 		log.Error("Unable to delete from collectionredirects: %v", err)
 		return err
 	}
 	return nil
 }
 
 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 && !db.isIgnorableError(err) {
 		log.Error("Failed selecting from collectionredirects: %v", err)
 	}
 	return
 }
 
 func (db *datastore) DeleteCollection(alias string, userID int64) error {
 	c := &Collection{Alias: alias}
 	var username string
 
 	row := db.QueryRow("SELECT username FROM users WHERE id = ?", userID)
 	err := row.Scan(&username)
 	if err != nil {
 		return err
 	}
 
 	// Ensure user isn't deleting their main blog
 	if alias == username {
 		return impart.HTTPError{http.StatusForbidden, "You cannot currently delete your primary blog."}
 	}
 
 	row = db.QueryRow("SELECT id FROM collections WHERE alias = ? AND owner_id = ?", alias, userID)
 	err = row.Scan(&c.ID)
 	switch {
 	case err == sql.ErrNoRows:
 		return impart.HTTPError{http.StatusNotFound, "Collection doesn't exist or you're not allowed to delete it."}
 	case err != nil:
 		log.Error("Failed selecting from collections: %v", err)
 		return ErrInternalGeneral
 	}
 
 	t, err := db.Begin()
 	if err != nil {
 		return err
 	}
 
 	// Float all collection's posts
 	_, err = t.Exec("UPDATE posts SET collection_id = NULL WHERE collection_id = ? AND owner_id = ?", c.ID, userID)
 	if err != nil {
 		t.Rollback()
 		return err
 	}
 
 	// Remove redirects to or from this collection
 	_, err = t.Exec("DELETE FROM collectionredirects WHERE prev_alias = ? OR new_alias = ?", alias, alias)
 	if err != nil {
 		t.Rollback()
 		return err
 	}
 
 	// Remove any optional collection password
 	_, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
 	if err != nil {
 		t.Rollback()
 		return err
 	}
 
 	// Finally, delete collection itself
 	_, err = t.Exec("DELETE FROM collections WHERE id = ?", c.ID)
 	if err != nil {
 		t.Rollback()
 		return err
 	}
 
 	err = t.Commit()
 	if err != nil {
 		t.Rollback()
 		return err
 	}
 
 	return nil
 }
 
 func (db *datastore) IsCollectionAttributeOn(id int64, attr string) bool {
 	var v string
 	err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v)
 	switch {
 	case err == sql.ErrNoRows:
 		return false
 	case err != nil:
 		log.Error("Couldn't SELECT value in isCollectionAttributeOn for attribute '%s': %v", attr, err)
 		return false
 	}
 	return v == "1"
 }
 
 func (db *datastore) CollectionHasAttribute(id int64, attr string) bool {
 	var dummy string
 	err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&dummy)
 	switch {
 	case err == sql.ErrNoRows:
 		return false
 	case err != nil:
 		log.Error("Couldn't SELECT value in collectionHasAttribute for attribute '%s': %v", attr, err)
 		return false
 	}
 	return true
 }
 
 func (db *datastore) GetCollectionAttribute(id int64, attr string) string {
 	var v string
 	err := db.QueryRow("SELECT value FROM collectionattributes WHERE collection_id = ? AND attribute = ?", id, attr).Scan(&v)
 	switch {
 	case err == sql.ErrNoRows:
 		return ""
 	case err != nil:
 		log.Error("Couldn't SELECT value in getCollectionAttribute for attribute '%s': %v", attr, err)
 		return ""
 	}
 	return v
 }
 
 func (db *datastore) SetCollectionAttribute(id int64, attr, v string) error {
 	_, err := db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?)", id, attr, v)
 	if err != nil {
 		log.Error("Unable to INSERT into collectionattributes: %v", err)
 		return err
 	}
 	return nil
 }
 
 // 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 {
 		log.Error("Unable to get collections: %v", err)
 		return err
 	}
 	defer rows.Close()
 	colls := []Collection{}
 	var c Collection
 	for rows.Next() {
 		err = rows.Scan(&c.ID, &c.Alias)
 		if err != nil {
 			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 {
 		// Delete tokens
 		res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID)
 		if err != nil {
 			t.Rollback()
 			log.Error("Unable to delete attributes on %s: %v", c.Alias, err)
 			return err
 		}
 		rs, _ := res.RowsAffected()
 		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()
 			log.Error("Unable to delete passwords on %s: %v", c.Alias, err)
 			return err
 		}
 		rs, _ = res.RowsAffected()
 		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()
 			log.Error("Unable to delete redirects on %s: %v", c.Alias, err)
 			return err
 		}
 		rs, _ = res.RowsAffected()
 		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()
 		log.Error("Unable to delete collections: %v", err)
 		return err
 	}
 	rs, _ := res.RowsAffected()
 	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()
 		log.Error("Unable to delete access tokens: %v", err)
 		return err
 	}
 	rs, _ = res.RowsAffected()
 	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()
 		log.Error("Unable to delete posts: %v", err)
 		return err
 	}
 	rs, _ = res.RowsAffected()
 	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()
 		log.Error("Unable to delete attributes: %v", err)
 		return err
 	}
 	rs, _ = res.RowsAffected()
 	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()
 		log.Error("Unable to delete user: %v", err)
 		return err
 	}
 	rs, _ = res.RowsAffected()
 	log.Info("Deleted %d from users", rs)
 
 	// Commit all changes to the database
 	err = t.Commit()
 	if err != nil {
 		t.Rollback()
 		log.Error("Unable to commit: %v", err)
 		return err
 	}
 
 	// TODO: federate delete actor
 
 	return nil
 }
 
 func (db *datastore) GetAPActorKeys(collectionID int64) ([]byte, []byte) {
 	var pub, priv []byte
 	err := db.QueryRow("SELECT public_key, private_key FROM collectionkeys WHERE collection_id = ?", collectionID).Scan(&pub, &priv)
 	switch {
 	case err == sql.ErrNoRows:
 		// Generate keys
 		pub, priv = activitypub.GenerateKeys()
 		_, err = db.Exec("INSERT INTO collectionkeys (collection_id, public_key, private_key) VALUES (?, ?, ?)", collectionID, pub, priv)
 		if err != nil {
 			log.Error("Unable to INSERT new activitypub keypair: %v", err)
 			return nil, nil
 		}
 	case err != nil:
 		log.Error("Couldn't SELECT collectionkeys: %v", err)
 		return nil, nil
 	}
 
 	return pub, priv
 }
 
 func (db *datastore) CreateUserInvite(id string, userID int64, maxUses int, expires *time.Time) error {
 	_, err := db.Exec("INSERT INTO userinvites (id, owner_id, max_uses, created, expires, inactive) VALUES (?, ?, ?, "+db.now()+", ?, 0)", id, userID, maxUses, expires)
 	return err
 }
 
 func (db *datastore) GetUserInvites(userID int64) (*[]Invite, error) {
 	rows, err := db.Query("SELECT id, max_uses, created, expires, inactive FROM userinvites WHERE owner_id = ? ORDER BY created DESC", userID)
 	if err != nil {
 		log.Error("Failed selecting from userinvites: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve user invites."}
 	}
 	defer rows.Close()
 
 	is := []Invite{}
 	for rows.Next() {
 		i := Invite{}
 		err = rows.Scan(&i.ID, &i.MaxUses, &i.Created, &i.Expires, &i.Inactive)
 		is = append(is, i)
 	}
 	return &is, nil
 }
 
 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, db.isIgnorableError(err):
 		return nil, impart.HTTPError{http.StatusNotFound, "Invite doesn't exist."}
 	case err != nil:
 		log.Error("Failed selecting invite: %v", err)
 		return nil, err
 	}
 
 	return &i, nil
 }
 
 // IsUsersInvite returns true if the user with ID created the invite with code
 // and an error other than sql no rows, if any. Will return false in the event
 // of an error.
 func (db *datastore) IsUsersInvite(code string, userID int64) (bool, error) {
 	var id string
 	err := db.QueryRow("SELECT id FROM userinvites WHERE id = ? AND owner_id = ?", code, userID).Scan(&id)
 	if err != nil && err != sql.ErrNoRows {
 		log.Error("Failed selecting invite: %v", err)
 		return false, err
 	}
 	return id != "", nil
 }
 
 func (db *datastore) GetUsersInvitedCount(id string) int64 {
 	var count int64
 	err := db.QueryRow("SELECT COUNT(*) FROM usersinvited WHERE invite_id = ?", id).Scan(&count)
 	switch {
 	case err == sql.ErrNoRows:
 		return 0
 	case err != nil:
 		log.Error("Failed selecting users invited count: %v", err)
 		return 0
 	}
 
 	return count
 }
 
 func (db *datastore) CreateInvitedUser(inviteID string, userID int64) error {
 	_, err := db.Exec("INSERT INTO usersinvited (invite_id, user_id) VALUES (?, ?)", inviteID, userID)
 	return err
 }
 
 func (db *datastore) GetInstancePages() ([]*instanceContent, error) {
 	return db.GetAllDynamicContent("page")
 }
 
 func (db *datastore) GetAllDynamicContent(t string) ([]*instanceContent, error) {
 	where := ""
 	params := []interface{}{}
 	if t != "" {
 		where = " WHERE content_type = ?"
 		params = append(params, t)
 	}
 	rows, err := db.Query("SELECT id, title, content, updated, content_type FROM appcontent"+where, params...)
 	if err != nil {
 		log.Error("Failed selecting from appcontent: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve instance pages."}
 	}
 	defer rows.Close()
 
 	pages := []*instanceContent{}
 	for rows.Next() {
 		c := &instanceContent{}
 		err = rows.Scan(&c.ID, &c.Title, &c.Content, &c.Updated, &c.Type)
 		if err != nil {
 			log.Error("Failed scanning row: %v", err)
 			break
 		}
 		pages = append(pages, c)
 	}
 	err = rows.Err()
 	if err != nil {
 		log.Error("Error after Next() on rows: %v", err)
 	}
 
 	return pages, nil
 }
 
 func (db *datastore) GetDynamicContent(id string) (*instanceContent, error) {
 	c := &instanceContent{
 		ID: id,
 	}
 	err := db.QueryRow("SELECT title, content, updated, content_type FROM appcontent WHERE id = ?", id).Scan(&c.Title, &c.Content, &c.Updated, &c.Type)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, nil
 	case err != nil:
 		log.Error("Couldn't SELECT FROM appcontent for id '%s': %v", id, err)
 		return nil, err
 	}
 	return c, nil
 }
 
 func (db *datastore) UpdateDynamicContent(id, title, content, contentType string) error {
 	var err error
 	if db.driverName == driverSQLite {
 		_, err = db.Exec("INSERT OR REPLACE INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?)", id, title, content, contentType)
 	} else {
 		_, err = db.Exec("INSERT INTO appcontent (id, title, content, updated, content_type) VALUES (?, ?, ?, "+db.now()+", ?) "+db.upsert("id")+" title = ?, content = ?, updated = "+db.now(), id, title, content, contentType, title, content)
 	}
 	if err != nil {
 		log.Error("Unable to INSERT appcontent for '%s': %v", id, err)
 	}
 	return err
 }
 
 func (db *datastore) GetAllUsers(page uint) (*[]User, error) {
 	limitStr := fmt.Sprintf("0, %d", adminUsersPerPage)
 	if page > 1 {
 		limitStr = fmt.Sprintf("%d, %d", (page-1)*adminUsersPerPage, adminUsersPerPage)
 	}
 
 	rows, err := db.Query("SELECT id, username, created, status FROM users ORDER BY created DESC LIMIT " + limitStr)
 	if err != nil {
 		log.Error("Failed selecting from users: %v", err)
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve all users."}
 	}
 	defer rows.Close()
 
 	users := []User{}
 	for rows.Next() {
 		u := User{}
 		err = rows.Scan(&u.ID, &u.Username, &u.Created, &u.Status)
 		if err != nil {
 			log.Error("Failed scanning GetAllUsers() row: %v", err)
 			break
 		}
 		users = append(users, u)
 	}
 	return &users, nil
 }
 
 func (db *datastore) GetAllUsersCount() int64 {
 	var count int64
 	err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
 	switch {
 	case err == sql.ErrNoRows:
 		return 0
 	case err != nil:
 		log.Error("Failed selecting all users count: %v", err)
 		return 0
 	}
 
 	return count
 }
 
 func (db *datastore) GetUserLastPostTime(id int64) (*time.Time, error) {
 	var t time.Time
 	err := db.QueryRow("SELECT created FROM posts WHERE owner_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, nil
 	case err != nil:
 		log.Error("Failed selecting last post time from posts: %v", err)
 		return nil, err
 	}
 	return &t, nil
 }
 
 // SetUserStatus changes a user's status in the database. see Users.UserStatus
 func (db *datastore) SetUserStatus(id int64, status UserStatus) error {
 	_, err := db.Exec("UPDATE users SET status = ? WHERE id = ?", status, id)
 	if err != nil {
 		return fmt.Errorf("failed to update user status: %v", err)
 	}
 	return nil
 }
 
 func (db *datastore) GetCollectionLastPostTime(id int64) (*time.Time, error) {
 	var t time.Time
 	err := db.QueryRow("SELECT created FROM posts WHERE collection_id = ? ORDER BY created DESC LIMIT 1", id).Scan(&t)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, nil
 	case err != nil:
 		log.Error("Failed selecting last post time from posts: %v", err)
 		return nil, err
 	}
 	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.
 func (db *datastore) DatabaseInitialized() bool {
 	var dummy string
 	var err error
 	if db.driverName == driverSQLite {
 		err = db.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'").Scan(&dummy)
 	} else {
 		err = db.QueryRow("SHOW TABLES LIKE 'users'").Scan(&dummy)
 	}
 	switch {
 	case err == sql.ErrNoRows:
 		return false
 	case err != nil:
 		log.Error("Couldn't SHOW TABLES: %v", err)
 		return false
 	}
 
 	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...)
 }
 
 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
 }
diff --git a/go.mod b/go.mod
index 9d44bbe..fc60c07 100644
--- a/go.mod
+++ b/go.mod
@@ -1,50 +1,50 @@
-module github.com/writeas/writefreely
+module github.com/writefreely/writefreely
 
 require (
 	github.com/clbanning/mxj v1.8.4 // indirect
 	github.com/dustin/go-humanize v1.0.0
 	github.com/fatih/color v1.10.0
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/go-test/deep v1.0.1 // indirect
 	github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
 	github.com/gorilla/feeds v1.1.1
 	github.com/gorilla/mux v1.8.0
 	github.com/gorilla/schema v1.2.0
 	github.com/gorilla/sessions v1.2.0
 	github.com/guregu/null v3.5.0+incompatible
 	github.com/hashicorp/go-multierror v1.1.1
 	github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
 	github.com/jtolds/gls v4.2.1+incompatible // indirect
 	github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
 	github.com/lunixbochs/vtclean v1.0.0 // indirect
 	github.com/manifoldco/promptui v0.8.0
 	github.com/mattn/go-sqlite3 v1.14.6
 	github.com/microcosm-cc/bluemonday v1.0.5
 	github.com/mitchellh/go-wordwrap v1.0.1
 	github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
 	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/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
 	github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
 	github.com/stretchr/testify v1.7.0
 	github.com/urfave/cli/v2 v2.3.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 v1.1.0
 	github.com/writeas/httpsig v1.0.0
 	github.com/writeas/impart v1.1.1
 	github.com/writeas/import v0.2.1
 	github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
 	github.com/writeas/nerds v1.0.0
 	github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
 	github.com/writeas/slug v1.2.0
 	github.com/writeas/web-core v1.3.0
 	github.com/writefreely/go-nodeinfo v1.2.0
 	golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
 	golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect
 	gopkg.in/ini.v1 v1.62.0
 )
 
 go 1.13
diff --git a/handle.go b/handle.go
index 5e15137..01d5728 100644
--- a/handle.go
+++ b/handle.go
@@ -1,935 +1,935 @@
 /*
- * Copyright © 2018-2019 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"fmt"
 	"html/template"
 	"net/http"
 	"net/url"
 	"runtime/debug"
 	"strconv"
 	"strings"
 	"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"
-	"github.com/writeas/writefreely/page"
+	"github.com/writefreely/writefreely/config"
+	"github.com/writefreely/writefreely/page"
 )
 
 // UserLevel represents the required user level for accessing an endpoint
 type UserLevel int
 
 const (
 	UserLevelNoneType         UserLevel = iota // user or not -- ignored
 	UserLevelOptionalType                      // user or not -- object fetched if user
 	UserLevelNoneRequiredType                  // non-user (required)
 	UserLevelUserType                          // user (required)
 )
 
 func UserLevelNone(cfg *config.Config) UserLevel {
 	return UserLevelNoneType
 }
 
 func UserLevelOptional(cfg *config.Config) UserLevel {
 	return UserLevelOptionalType
 }
 
 func UserLevelNoneRequired(cfg *config.Config) UserLevel {
 	return UserLevelNoneRequiredType
 }
 
 func UserLevelUser(cfg *config.Config) UserLevel {
 	return UserLevelUserType
 }
 
 // UserLevelReader returns the permission level required for any route where
 // users can read published content.
 func UserLevelReader(cfg *config.Config) UserLevel {
 	if cfg.App.Private {
 		return UserLevelUserType
 	}
 	return UserLevelOptionalType
 }
 
 type (
 	handlerFunc          func(app *App, w http.ResponseWriter, r *http.Request) error
 	gopherFunc           func(app *App, w gopher.ResponseWriter, r *gopher.Request) error
 	userHandlerFunc      func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
 	userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error
 	dataHandlerFunc      func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
 	authFunc             func(app *App, r *http.Request) (*User, error)
 	UserLevelFunc        func(cfg *config.Config) UserLevel
 )
 
 type Handler struct {
 	errors       *ErrorPages
 	sessionStore sessions.Store
 	app          Apper
 }
 
 // ErrorPages hold template HTML error pages for displaying errors to the user.
 // In each, there should be a defined template named "base".
 type ErrorPages struct {
 	NotFound            *template.Template
 	Gone                *template.Template
 	InternalServerError *template.Template
 	UnavailableError    *template.Template
 	Blank               *template.Template
 }
 
 // NewHandler returns a new Handler instance, using the given StaticPage data,
 // and saving alias to the application's CookieStore.
 func NewHandler(apper Apper) *Handler {
 	h := &Handler{
 		errors: &ErrorPages{
 			NotFound:            template.Must(template.New("").Parse("{{define \"base\"}}404Not found.
{{end}}")),
 			Gone:                template.Must(template.New("").Parse("{{define \"base\"}}410Gone.
{{end}}")),
 			InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}500Internal server error.
{{end}}")),
 			UnavailableError:    template.Must(template.New("").Parse("{{define \"base\"}}503Service is temporarily unavailable.
{{end}}")),
 			Blank:               template.Must(template.New("").Parse("{{define \"base\"}}{{.Title}}{{.Content}}
{{end}}")),
 		},
 		sessionStore: apper.App().SessionStore(),
 		app:          apper,
 	}
 
 	return h
 }
 
 // NewWFHandler returns a new Handler instance, using WriteFreely template files.
 // You MUST call writefreely.InitTemplates() before this.
 func NewWFHandler(apper Apper) *Handler {
 	h := NewHandler(apper)
 	h.SetErrorPages(&ErrorPages{
 		NotFound:            pages["404-general.tmpl"],
 		Gone:                pages["410.tmpl"],
 		InternalServerError: pages["500.tmpl"],
 		UnavailableError:    pages["503.tmpl"],
 		Blank:               pages["blank.tmpl"],
 	})
 	return h
 }
 
 // SetErrorPages sets the given set of ErrorPages as templates for any errors
 // that come up.
 func (h *Handler) SetErrorPages(e *ErrorPages) {
 	h.errors = e
 }
 
 // User handles requests made in the web application by the authenticated user.
 // This provides user-friendly HTML pages and actions that work in the browser.
 func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = http.StatusInternalServerError
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			u := getUserSession(h.app.App(), r)
 			if u == nil {
 				err := ErrNotLoggedIn
 				status = err.Status
 				return err
 			}
 
 			err := f(h.app.App(), u, w, r)
 			if err == nil {
 				status = http.StatusOK
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = http.StatusInternalServerError
 			}
 
 			return err
 		}())
 	}
 }
 
 // Admin handles requests on /admin routes
 func (h *Handler) Admin(f userHandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = http.StatusInternalServerError
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			u := getUserSession(h.app.App(), r)
 			if u == nil || !u.IsAdmin() {
 				err := impart.HTTPError{http.StatusNotFound, ""}
 				status = err.Status
 				return err
 			}
 
 			err := f(h.app.App(), u, w, r)
 			if err == nil {
 				status = http.StatusOK
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = http.StatusInternalServerError
 			}
 
 			return err
 		}())
 	}
 }
 
 // AdminApper handles requests on /admin routes that require an Apper.
 func (h *Handler) AdminApper(f userApperHandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = http.StatusInternalServerError
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			u := getUserSession(h.app.App(), r)
 			if u == nil || !u.IsAdmin() {
 				err := impart.HTTPError{http.StatusNotFound, ""}
 				status = err.Status
 				return err
 			}
 
 			err := f(h.app, u, w, r)
 			if err == nil {
 				status = http.StatusOK
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = http.StatusInternalServerError
 			}
 
 			return err
 		}())
 	}
 }
 
 func apiAuth(app *App, r *http.Request) (*User, error) {
 	// Authorize user from Authorization header
 	t := r.Header.Get("Authorization")
 	if t == "" {
 		return nil, ErrNoAccessToken
 	}
 	u := &User{ID: app.db.GetUserID(t)}
 	if u.ID == -1 {
 		return nil, ErrBadAccessToken
 	}
 
 	return u, nil
 }
 
 // optionaAPIAuth is used for endpoints that accept authenticated requests via
 // Authorization header or cookie, unlike apiAuth. It returns a different err
 // in the case where no Authorization header is present.
 func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
 	// Authorize user from Authorization header
 	t := r.Header.Get("Authorization")
 	if t == "" {
 		return nil, ErrNotLoggedIn
 	}
 	u := &User{ID: app.db.GetUserID(t)}
 	if u.ID == -1 {
 		return nil, ErrBadAccessToken
 	}
 
 	return u, nil
 }
 
 func webAuth(app *App, r *http.Request) (*User, error) {
 	u := getUserSession(app, r)
 	if u == nil {
 		return nil, ErrNotLoggedIn
 	}
 	return u, nil
 }
 
 // UserAPI handles requests made in the API by the authenticated user.
 // This provides user-friendly HTML pages and actions that work in the browser.
 func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
 	return h.UserAll(false, f, apiAuth)
 }
 
 func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		handleFunc := func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %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)))
 			}()
 
 			u, err := a(h.app.App(), r)
 			if err != nil {
 				if err, ok := err.(impart.HTTPError); ok {
 					status = err.Status
 				} else {
 					status = 500
 				}
 				return err
 			}
 
 			err = f(h.app.App(), u, w, r)
 			if err == nil {
 				status = 200
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = 500
 			}
 
 			return err
 		}
 
 		if web {
 			h.handleHTTPError(w, r, handleFunc())
 		} else {
 			h.handleError(w, r, handleFunc())
 		}
 	}
 }
 
 func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
 	return func(app *App, w http.ResponseWriter, r *http.Request) error {
 		err := f(app, w, r)
 		if err != nil {
 			if ie, ok := err.(impart.HTTPError); ok {
 				// Override default redirect with returned error's, if it's a
 				// redirect error.
 				if ie.Status == http.StatusFound {
 					return ie
 				}
 			}
 			return impart.HTTPError{http.StatusFound, loc}
 		}
 		return nil
 	}
 }
 
 func (h *Handler) Page(n string) http.HandlerFunc {
 	return h.Web(func(app *App, w http.ResponseWriter, r *http.Request) error {
 		t, ok := pages[n]
 		if !ok {
 			return impart.HTTPError{http.StatusNotFound, "Page not found."}
 		}
 
 		sp := pageForReq(app, r)
 
 		err := t.ExecuteTemplate(w, "base", sp)
 		if err != nil {
 			log.Error("Unable to render page: %v", err)
 		}
 		return err
 	}, UserLevelOptional)
 }
 
 func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		// TODO: factor out this logic shared with Web()
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					u := getUserSession(h.app.App(), r)
 					username := "None"
 					if u != nil {
 						username = u.Username
 					}
 					log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			var session *sessions.Session
 			var err error
 			if ul(h.app.App().cfg) != UserLevelNoneType {
 				session, err = h.sessionStore.Get(r, cookieName)
 				if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
 					// Cookie is required, but we can ignore this error
 					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
 				}
 
 				_, gotUser := session.Values[cookieUserVal].(*User)
 				if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
 					to := correctPageFromLoginAttempt(r)
 					log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
 					err := impart.HTTPError{http.StatusFound, to}
 					status = err.Status
 					return err
 				} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
 					log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
 					err := ErrNotLoggedIn
 					status = err.Status
 					return err
 				}
 			}
 
 			// TODO: pass User object to function
 			err = f(h.app.App(), w, r)
 			if err == nil {
 				status = 200
 			} else if httpErr, ok := err.(impart.HTTPError); ok {
 				status = httpErr.Status
 				if status < 300 || status > 399 {
 					addSessionFlash(h.app.App(), w, r, httpErr.Message, session)
 					return impart.HTTPError{http.StatusFound, r.Referer()}
 				}
 			} else {
 				e := fmt.Sprintf("[Web handler] 500: %v", err)
 				if !strings.HasSuffix(e, "write: broken pipe") {
 					log.Error(e)
 				} else {
 					log.Error(e)
 				}
 				log.Info("Web handler internal error render")
 				h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 				status = 500
 			}
 
 			return err
 		}())
 	}
 }
 
 func (h *Handler) CollectionPostOrStatic(w http.ResponseWriter, r *http.Request) {
 	if strings.Contains(r.URL.Path, ".") && !isRaw(r) {
 		start := time.Now()
 		status := 200
 		defer func() {
 			log.Info(h.app.ReqLog(r, status, time.Since(start)))
 		}()
 
 		// Serve static file
 		h.app.App().shttp.ServeHTTP(w, r)
 		return
 	}
 
 	h.Web(viewCollectionPost, UserLevelReader)(w, r)
 }
 
 // Web handles requests made in the web application. This provides user-
 // friendly HTML pages and actions that work in the browser.
 func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					u := getUserSession(h.app.App(), r)
 					username := "None"
 					if u != nil {
 						username = u.Username
 					}
 					log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
 					log.Info("Web deferred internal error render")
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			if ul(h.app.App().cfg) != UserLevelNoneType {
 				session, err := h.sessionStore.Get(r, cookieName)
 				if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
 					// Cookie is required, but we can ignore this error
 					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
 				}
 
 				_, gotUser := session.Values[cookieUserVal].(*User)
 				if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
 					to := correctPageFromLoginAttempt(r)
 					log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
 					err := impart.HTTPError{http.StatusFound, to}
 					status = err.Status
 					return err
 				} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
 					log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
 					err := ErrNotLoggedIn
 					status = err.Status
 					return err
 				}
 			}
 
 			// TODO: pass User object to function
 			err := f(h.app.App(), w, r)
 			if err == nil {
 				status = 200
 			} else if httpErr, ok := err.(impart.HTTPError); ok {
 				status = httpErr.Status
 			} else {
 				e := fmt.Sprintf("[Web handler] 500: %v", err)
 				log.Error(e)
 				log.Info("Web internal error render")
 				h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 				status = 500
 			}
 
 			return err
 		}())
 	}
 }
 
 func (h *Handler) All(f handlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleError(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)))
 			}()
 
 			// TODO: do any needed authentication
 
 			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) OAuth(f handlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleOAuthError(w, r, func() error {
 			// TODO: return correct "success" status
 			status := 200
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s:\n%s", e, debug.Stack())
 					impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
 					status = 500
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			err := f(h.app.App(), w, r)
 			if err != nil {
 				if err, ok := err.(impart.HTTPError); ok {
 					status = err.Status
 				} else {
 					status = 500
 				}
 			}
 
 			return err
 		}())
 	}
 }
 
 func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleError(w, r, func() error {
 			status := 200
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s:\n%s", e, debug.Stack())
 					impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
 					status = 500
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			// Allow any origin, as public endpoints are handled in here
-			w.Header().Set("Access-Control-Allow-Origin", "*");
+			w.Header().Set("Access-Control-Allow-Origin", "*")
 
 			if h.app.App().cfg.App.Private {
 				// This instance is private, so ensure it's being accessed by a valid user
 				// Check if authenticated with an access token
 				_, apiErr := optionalAPIAuth(h.app.App(), r)
 				if apiErr != nil {
 					if err, ok := apiErr.(impart.HTTPError); ok {
 						status = err.Status
 					} else {
 						status = 500
 					}
 
 					if apiErr == ErrNotLoggedIn {
 						// Fall back to web auth since there was no access token given
 						_, err := webAuth(h.app.App(), r)
 						if err != nil {
 							if err, ok := apiErr.(impart.HTTPError); ok {
 								status = err.Status
 							} else {
 								status = 500
 							}
 							return err
 						}
 					} else {
 						return apiErr
 					}
 				}
 			}
 
 			err := f(h.app.App(), w, r)
 			if err != nil {
 				if err, ok := err.(impart.HTTPError); ok {
 					status = err.Status
 				} else {
 					status = 500
 				}
 			}
 
 			return err
 		}())
 	}
 }
 
 func (h *Handler) Download(f dataHandlerFunc, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			data, filename, 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
 			}
 
 			ext := ".json"
 			ct := "application/json"
 			if strings.HasSuffix(r.URL.Path, ".csv") {
 				ext = ".csv"
 				ct = "text/csv"
 			} else if strings.HasSuffix(r.URL.Path, ".zip") {
 				ext = ".zip"
 				ct = "application/zip"
 			}
 			w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s%s", filename, ext))
 			w.Header().Set("Content-Type", ct)
 			w.Header().Set("Content-Length", strconv.Itoa(len(data)))
 			fmt.Fprint(w, string(data))
 
 			status = 200
 			return nil
 		}())
 	}
 }
 
 func (h *Handler) Redirect(url string, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			start := time.Now()
 
 			var status int
 			if ul(h.app.App().cfg) != UserLevelNoneType {
 				session, err := h.sessionStore.Get(r, cookieName)
 				if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
 					// Cookie is required, but we can ignore this error
 					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
 				}
 
 				_, gotUser := session.Values[cookieUserVal].(*User)
 				if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
 					to := correctPageFromLoginAttempt(r)
 					log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
 					err := impart.HTTPError{http.StatusFound, to}
 					status = err.Status
 					return err
 				} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
 					log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
 					err := ErrNotLoggedIn
 					status = err.Status
 					return err
 				}
 			}
 
 			status = sendRedirect(w, http.StatusFound, url)
 
 			log.Info(h.app.ReqLog(r, status, time.Since(start)))
 
 			return nil
 		}())
 	}
 }
 
 func (h *Handler) handleHTTPError(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
 		} else if err.Status == http.StatusUnauthorized {
 			q := ""
 			if r.URL.RawQuery != "" {
 				q = url.QueryEscape("?" + r.URL.RawQuery)
 			}
 			sendRedirect(w, http.StatusFound, "/login?to="+r.URL.Path+q)
 			return
 		} else if err.Status == http.StatusGone {
 			w.WriteHeader(err.Status)
 			p := &struct {
 				page.StaticPage
 				Content *template.HTML
 			}{
 				StaticPage: pageForReq(h.app.App(), r),
 			}
 			if err.Message != "" {
 				co := template.HTML(err.Message)
 				p.Content = &co
 			}
 			h.errors.Gone.ExecuteTemplate(w, "base", p)
 			return
 		} else if err.Status == http.StatusNotFound {
 			w.WriteHeader(err.Status)
 			if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
 				// This is a fediverse request; simply return the header
 				return
 			}
 			h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 			return
 		} else if err.Status == http.StatusInternalServerError {
 			w.WriteHeader(err.Status)
 			log.Info("handleHTTPErorr internal error render")
 			h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 			return
 		} else if err.Status == http.StatusServiceUnavailable {
 			w.WriteHeader(err.Status)
 			h.errors.UnavailableError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 			return
 		} else if err.Status == http.StatusAccepted {
 			impart.WriteSuccess(w, "", err.Status)
 			return
 		} else {
 			p := &struct {
 				page.StaticPage
 				Title   string
 				Content template.HTML
 			}{
 				pageForReq(h.app.App(), r),
 				fmt.Sprintf("Uh oh (%d)", err.Status),
 				template.HTML(fmt.Sprintf("%s
", err.Message)),
 			}
 			h.errors.Blank.ExecuteTemplate(w, "base", p)
 			return
 		}
 		impart.WriteError(w, err)
 		return
 	}
 
 	impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
 }
 
 func (h *Handler) handleError(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
 		}
 
 		//		if strings.Contains(r.Header.Get("Accept"), "text/html") {
 		impart.WriteError(w, err)
 		//		}
 		return
 	}
 
 	if IsJSON(r) {
 		impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
 		return
 	}
 	h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 }
 
 func (h *Handler) 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 == "" {
 		to = "/"
 	} else if !strings.HasPrefix(to, "/") {
 		to = "/" + to
 	}
 	return to
 }
 
 func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			status := 200
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("Handler.LogHandlerFunc\n\n%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				// TODO: log actual status code returned
 				log.Info(h.app.ReqLog(r, status, time.Since(start)))
 			}()
 
 			if h.app.App().cfg.App.Private {
 				// This instance is private, so ensure it's being accessed by a valid user
 				// Check if authenticated with an access token
 				_, apiErr := optionalAPIAuth(h.app.App(), r)
 				if apiErr != nil {
 					if err, ok := apiErr.(impart.HTTPError); ok {
 						status = err.Status
 					} else {
 						status = 500
 					}
 
 					if apiErr == ErrNotLoggedIn {
 						// Fall back to web auth since there was no access token given
 						_, err := webAuth(h.app.App(), r)
 						if err != nil {
 							if err, ok := apiErr.(impart.HTTPError); ok {
 								status = err.Status
 							} else {
 								status = 500
 							}
 							return err
 						}
 					} else {
 						return apiErr
 					}
 				}
 			}
 
 			f(w, r)
 
 			return nil
 		}())
 	}
 }
 
 func (h *Handler) Gopher(f gopherFunc) gopher.HandlerFunc {
 	return func(w gopher.ResponseWriter, r *gopher.Request) {
 		defer func() {
 			if e := recover(); e != nil {
 				log.Error("%s: %s", e, debug.Stack())
 				w.WriteError("An internal error occurred")
 			}
 			log.Info("gopher: %s", r.Selector)
 		}()
 
 		err := f(h.app.App(), w, r)
 		if err != nil {
 			log.Error("failed: %s", err)
 			w.WriteError("the page failed for some reason (see logs)")
 		}
 	}
 }
 
 func sendRedirect(w http.ResponseWriter, code int, location string) int {
 	w.Header().Set("Location", location)
 	w.WriteHeader(code)
 	return code
 }
 
 func cacheControl(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
 		next.ServeHTTP(w, r)
 	})
 }
diff --git a/invites.go b/invites.go
index 4e3eff4..60aa482 100644
--- a/invites.go
+++ b/invites.go
@@ -1,203 +1,203 @@
 /*
- * Copyright © 2019-2020 A Bunch Tell LLC.
+ * Copyright © 2019-2021 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 (
 	"database/sql"
 	"html/template"
 	"net/http"
 	"strconv"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/writeas/impart"
 	"github.com/writeas/nerds/store"
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/page"
+	"github.com/writefreely/writefreely/page"
 )
 
 type Invite struct {
 	ID       string
 	MaxUses  sql.NullInt64
 	Created  time.Time
 	Expires  *time.Time
 	Inactive bool
 
 	uses int64
 }
 
 func (i Invite) Uses() int64 {
 	return i.uses
 }
 
 func (i Invite) Expired() bool {
 	return i.Expires != nil && i.Expires.Before(time.Now())
 }
 
 func (i Invite) Active(db *datastore) bool {
 	if i.Expired() {
 		return false
 	}
 	if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
 		if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
 			return false
 		}
 	}
 	return true
 }
 
 func (i Invite) ExpiresFriendly() string {
 	return i.Expires.Format("January 2, 2006, 3:04 PM")
 }
 
 func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	// Don't show page if instance doesn't allow it
 	if !(app.cfg.App.UserInvites != "" && (u.IsAdmin() || app.cfg.App.UserInvites != "admin")) {
 		return impart.HTTPError{http.StatusNotFound, ""}
 	}
 
 	f, _ := getSessionFlashes(app, w, r, nil)
 
 	p := struct {
 		*UserPage
 		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
 	}
 	for i := range *p.Invites {
 		(*p.Invites)[i].uses = app.db.GetUsersInvitedCount((*p.Invites)[i].ID)
 	}
 
 	showUserPage(w, "invite", p)
 	return nil
 }
 
 func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	muVal := r.FormValue("uses")
 	expVal := r.FormValue("expires")
 
 	if u.IsSilenced() {
 		return ErrUserSilenced
 	}
 
 	var err error
 	var maxUses int
 	if muVal != "0" {
 		maxUses, err = strconv.Atoi(muVal)
 		if err != nil {
 			return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'max_uses'"}
 		}
 	}
 
 	var expDate *time.Time
 	var expires int
 	if expVal != "0" {
 		expires, err = strconv.Atoi(expVal)
 		if err != nil {
 			return impart.HTTPError{http.StatusBadRequest, "Invalid value for 'expires'"}
 		}
 		ed := time.Now().Add(time.Duration(expires) * time.Minute)
 		expDate = &ed
 	}
 
 	inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
 	err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate)
 	if err != nil {
 		return err
 	}
 
 	return impart.HTTPError{http.StatusFound, "/me/invites"}
 }
 
 func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
 	inviteCode := mux.Vars(r)["code"]
 
 	i, err := app.db.GetUserInvite(inviteCode)
 	if err != nil {
 		return err
 	}
 
 	expired := i.Expired()
 	if !expired && i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
 		// Invite has a max-use number, so check if we're past that limit
 		i.uses = app.db.GetUsersInvitedCount(inviteCode)
 		expired = i.uses >= i.MaxUses.Int64
 	}
 
 	if u := getUserSession(app, r); u != nil {
 		// check if invite belongs to another user
 		// error can be ignored as not important in this case
 		if ownInvite, _ := app.db.IsUsersInvite(inviteCode, u.ID); !ownInvite {
 			addSessionFlash(app, w, r, "You're already registered and logged in.", nil)
 			// show homepage
 			return impart.HTTPError{http.StatusFound, "/me/settings"}
 		}
 
 		// show invite instructions
 		p := struct {
 			*UserPage
 			Invite  *Invite
 			Expired bool
 		}{
 			UserPage: NewUserPage(app, r, u, "Invite to "+app.cfg.App.SiteName, nil),
 			Invite:   i,
 			Expired:  expired,
 		}
 		showUserPage(w, "invite-help", p)
 		return nil
 	}
 
 	p := struct {
 		page.StaticPage
 		*OAuthButtons
 		Error   string
 		Flashes []template.HTML
 		Invite  string
 	}{
 		StaticPage:   pageForReq(app, r),
 		OAuthButtons: NewOAuthButtons(app.cfg),
 		Invite:       inviteCode,
 	}
 
 	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 {
 		// Ignore this
 		log.Error("Unable to get session in handleViewInvite; ignoring: %v", err)
 	}
 	flashes, _ := getSessionFlashes(app, w, r, session)
 	for _, flash := range flashes {
 		p.Flashes = append(p.Flashes, template.HTML(flash))
 	}
 
 	// Show landing page
 	return renderPage(w, "signup.tmpl", p)
 }
diff --git a/keys.go b/keys.go
index 5cc63a3..e53d811 100644
--- a/keys.go
+++ b/keys.go
@@ -1,73 +1,73 @@
 /*
- * Copyright © 2018-2019 A Bunch Tell LLC.
+ * Copyright © 2018-2019, 2021 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 (
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/key"
+	"github.com/writefreely/writefreely/key"
 	"io/ioutil"
 	"os"
 	"path/filepath"
 )
 
 const (
 	keysDir = "keys"
 )
 
 var (
 	emailKeyPath      = filepath.Join(keysDir, "email.aes256")
 	cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256")
 	cookieKeyPath     = filepath.Join(keysDir, "cookies_enc.aes256")
 )
 
 // InitKeys loads encryption keys into memory via the given Apper interface
 func InitKeys(apper Apper) error {
 	log.Info("Loading encryption keys...")
 	err := apper.LoadKeys()
 	if err != nil {
 		return err
 	}
 	return nil
 }
 
 func initKeyPaths(app *App) {
 	emailKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, emailKeyPath)
 	cookieAuthKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieAuthKeyPath)
 	cookieKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieKeyPath)
 }
 
 // generateKey generates a key at the given path used for the encryption of
 // certain user data. Because user data becomes unrecoverable without these
 // keys, this won't overwrite any existing key, and instead outputs a message.
 func generateKey(path string) error {
 	// Check if key file exists
 	if _, err := os.Stat(path); err == nil {
 		log.Info("%s already exists. rm the file if you understand the consquences.", path)
 		return nil
 	} else if !os.IsNotExist(err) {
 		log.Error("%s", err)
 		return err
 	}
 
 	log.Info("Generating %s.", path)
 	b, err := key.GenerateBytes(key.EncKeysBytes)
 	if err != nil {
 		log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
 		return err
 	}
 	err = ioutil.WriteFile(path, b, 0600)
 	if err != nil {
 		log.Error("FAILED writing file: %s", err)
 		return err
 	}
 	log.Info("Success.")
 	return nil
 }
diff --git a/migrations/v4.go b/migrations/v4.go
index 7d73f96..c69dce1 100644
--- a/migrations/v4.go
+++ b/migrations/v4.go
@@ -1,54 +1,54 @@
 /*
- * Copyright © 2019-2020 A Bunch Tell LLC.
+ * Copyright © 2019-2021 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"
+	wf_db "github.com/writefreely/writefreely/db"
 )
 
 func oauth(db *datastore) error {
 	dialect := wf_db.DialectMySQL
 	if db.driverName == driverSQLite {
 		dialect = wf_db.DialectSQLite
 	}
 	return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
 		createTableUsersOauth, err := dialect.
 			Table("oauth_users").
 			SetIfNotExists(false).
 			Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
 			Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
 			ToSQL()
 		if err != nil {
 			return err
 		}
 		createTableOauthClientState, err := dialect.
 			Table("oauth_client_states").
 			SetIfNotExists(false).
 			Column(dialect.Column("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})).
 			Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)).
 			Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefaultCurrentTimestamp()).
 			UniqueConstraint("state").
 			ToSQL()
 		if err != nil {
 			return err
 		}
 
 		for _, table := range []string{createTableUsersOauth, createTableOauthClientState} {
 			if _, err := tx.ExecContext(ctx, table); err != nil {
 				return err
 			}
 		}
 		return nil
 	})
 }
diff --git a/migrations/v5.go b/migrations/v5.go
index f93d067..1fe3e30 100644
--- a/migrations/v5.go
+++ b/migrations/v5.go
@@ -1,88 +1,88 @@
 /*
- * Copyright © 2019-2020 A Bunch Tell LLC.
+ * Copyright © 2019-2021 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"
+	wf_db "github.com/writefreely/writefreely/db"
 )
 
 func oauthSlack(db *datastore) error {
 	dialect := wf_db.DialectMySQL
 	if db.driverName == driverSQLite {
 		dialect = wf_db.DialectSQLite
 	}
 	return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
 		builders := []wf_db.SQLBuilder{
 			dialect.
 				AlterTable("oauth_client_states").
 				AddColumn(dialect.
 					Column(
 						"provider",
 						wf_db.ColumnTypeVarChar,
 						wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
 			dialect.
 				AlterTable("oauth_client_states").
 				AddColumn(dialect.
 					Column(
 						"client_id",
 						wf_db.ColumnTypeVarChar,
 						wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
 			dialect.
 				AlterTable("oauth_users").
 				AddColumn(dialect.
 					Column(
 						"provider",
 						wf_db.ColumnTypeVarChar,
 						wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
 			dialect.
 				AlterTable("oauth_users").
 				AddColumn(dialect.
 					Column(
 						"client_id",
 						wf_db.ColumnTypeVarChar,
 						wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
 			dialect.
 				AlterTable("oauth_users").
 				AddColumn(dialect.
 					Column(
 						"access_token",
 						wf_db.ColumnTypeVarChar,
 						wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
 			dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
 		}
 
 		if dialect != wf_db.DialectSQLite {
 			// This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases.
 			builders = append(builders, dialect.
 				AlterTable("oauth_users").
 				ChangeColumn("remote_user_id",
 					dialect.
 						Column(
 							"remote_user_id",
 							wf_db.ColumnTypeVarChar,
 							wf_db.OptionalInt{Set: true, Value: 128})))
 		}
 
 		for _, builder := range builders {
 			query, err := builder.ToSQL()
 			if err != nil {
 				return err
 			}
 			if _, err := tx.ExecContext(ctx, query); err != nil {
 				return err
 			}
 		}
 		return nil
 	})
 }
diff --git a/migrations/v7.go b/migrations/v7.go
index 3090cd9..5737b21 100644
--- a/migrations/v7.go
+++ b/migrations/v7.go
@@ -1,36 +1,46 @@
+/*
+ * Copyright © 2020-2021 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"
+	wf_db "github.com/writefreely/writefreely/db"
 )
 
 func oauthAttach(db *datastore) error {
 	dialect := wf_db.DialectMySQL
 	if db.driverName == driverSQLite {
 		dialect = wf_db.DialectSQLite
 	}
 	return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
 		builders := []wf_db.SQLBuilder{
 			dialect.
 				AlterTable("oauth_client_states").
 				AddColumn(dialect.
 					Column(
 						"attach_user_id",
 						wf_db.ColumnTypeInteger,
 						wf_db.OptionalInt{Set: true, Value: 24}).SetNullable(true)),
 		}
 		for _, builder := range builders {
 			query, err := builder.ToSQL()
 			if err != nil {
 				return err
 			}
 			if _, err := tx.ExecContext(ctx, query); err != nil {
 				return err
 			}
 		}
 		return nil
 	})
 }
diff --git a/migrations/v8.go b/migrations/v8.go
index 2318c4e..28af523 100644
--- a/migrations/v8.go
+++ b/migrations/v8.go
@@ -1,45 +1,45 @@
 /*
- * Copyright © 2020 A Bunch Tell LLC.
+ * Copyright © 2020-2021 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"
+	wf_db "github.com/writefreely/writefreely/db"
 )
 
 func oauthInvites(db *datastore) error {
 	dialect := wf_db.DialectMySQL
 	if db.driverName == driverSQLite {
 		dialect = wf_db.DialectSQLite
 	}
 	return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
 		builders := []wf_db.SQLBuilder{
 			dialect.
 				AlterTable("oauth_client_states").
 				AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{
 					Set:   true,
 					Value: 6,
 				}).SetNullable(true)),
 		}
 		for _, builder := range builders {
 			query, err := builder.ToSQL()
 			if err != nil {
 				return err
 			}
 			if _, err := tx.ExecContext(ctx, query); err != nil {
 				return err
 			}
 		}
 		return nil
 	})
 }
diff --git a/nodeinfo.go b/nodeinfo.go
index 944a5df..f0c0b5e 100644
--- a/nodeinfo.go
+++ b/nodeinfo.go
@@ -1,115 +1,115 @@
 /*
- * Copyright © 2018 A Bunch Tell LLC.
+ * Copyright © 2018-2019, 2021 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 (
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/config"
 	"github.com/writefreely/go-nodeinfo"
+	"github.com/writefreely/writefreely/config"
 	"strings"
 )
 
 type nodeInfoResolver struct {
 	cfg *config.Config
 	db  *datastore
 }
 
 func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config {
 	name := cfg.App.SiteName
 	desc := cfg.App.SiteDesc
 	if desc == "" {
 		desc = "Minimal, federated blogging platform."
 	}
 	if cfg.App.SingleUser {
 		// Fetch blog information, instead
 		coll, err := db.GetCollectionByID(1)
 		if err == nil {
 			desc = coll.Description
 		}
 	}
 	return &nodeinfo.Config{
 		BaseURL: cfg.App.Host,
 		InfoURL: "/api/nodeinfo",
 
 		Metadata: nodeinfo.Metadata{
 			NodeName:        name,
 			NodeDescription: desc,
 			Private:         cfg.App.Private,
 			Software: nodeinfo.SoftwareMeta{
 				HomePage: softwareURL,
-				GitHub:   "https://github.com/writeas/writefreely",
+				GitHub:   "https://github.com/writefreely/writefreely",
 				Follow:   "https://writing.exchange/@write_as",
 			},
 			MaxBlogs:     cfg.App.MaxBlogs,
 			PublicReader: cfg.App.LocalTimeline,
 			Invites:      cfg.App.UserInvites != "",
 		},
 		Protocols: []nodeinfo.NodeProtocol{
 			nodeinfo.ProtocolActivityPub,
 		},
 		Services: nodeinfo.Services{
 			Inbound: []nodeinfo.NodeService{},
 			Outbound: []nodeinfo.NodeService{
 				nodeinfo.ServiceRSS,
 			},
 		},
 		Software: nodeinfo.SoftwareInfo{
 			Name:    strings.ToLower(serverSoftware),
 			Version: softwareVer,
 		},
 	}
 }
 
 func (r nodeInfoResolver) IsOpenRegistration() (bool, error) {
 	return r.cfg.App.OpenRegistration, nil
 }
 
 func (r nodeInfoResolver) Usage() (nodeinfo.Usage, error) {
 	var collCount, postCount int64
 	var activeHalfYear, activeMonth int
 	var err error
 	collCount, err = r.db.GetTotalCollections()
 	if err != nil {
 		collCount = 0
 	}
 	postCount, err = r.db.GetTotalPosts()
 	if err != nil {
 		log.Error("Unable to fetch post counts: %v", err)
 	}
 
 	if r.cfg.App.PublicStats {
 		// Display bi-yearly / monthly stats
 		err = r.db.QueryRow(`SELECT COUNT(*) FROM (
 SELECT DISTINCT collection_id
 FROM posts
 INNER JOIN collections c
 ON collection_id = c.id
 WHERE collection_id IS NOT NULL
 	AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear)
 
 		err = r.db.QueryRow(`SELECT COUNT(*) FROM (
 SELECT DISTINCT collection_id
 FROM posts
 INNER JOIN FROM collections c
 ON collection_id = c.id
 WHERE collection_id IS NOT NULL
 	AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth)
 	}
 
 	return nodeinfo.Usage{
 		Users: nodeinfo.UsageUsers{
 			Total:          int(collCount),
 			ActiveHalfYear: activeHalfYear,
 			ActiveMonth:    activeMonth,
 		},
 		LocalPosts: int(postCount),
 	}, nil
 }
diff --git a/oauth.go b/oauth.go
index 6f3598f..e28e21a 100644
--- a/oauth.go
+++ b/oauth.go
@@ -1,467 +1,467 @@
 /*
- * Copyright © 2019-2020 A Bunch Tell LLC.
+ * Copyright © 2019-2021 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"
+	"github.com/writefreely/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,
 			Scope:            config.OrDefaultString(app.Config().GenericOauth.Scope, "read_user"),
 			MapUserID:        config.OrDefaultString(app.Config().GenericOauth.MapUserID, "user_id"),
 			MapUsername:      config.OrDefaultString(app.Config().GenericOauth.MapUsername, "username"),
 			MapDisplayName:   config.OrDefaultString(app.Config().GenericOauth.MapDisplayName, "-"),
 			MapEmail:         config.OrDefaultString(app.Config().GenericOauth.MapEmail, "email"),
 		}
 		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
 }
diff --git a/oauth_signup.go b/oauth_signup.go
index cbe4f60..b1256be 100644
--- a/oauth_signup.go
+++ b/oauth_signup.go
@@ -1,231 +1,231 @@
 /*
- * Copyright © 2020 A Bunch Tell LLC.
+ * Copyright © 2020-2021 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"
+	"github.com/writefreely/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
 }
diff --git a/oauth_test.go b/oauth_test.go
index f454f1a..cc5f108 100644
--- a/oauth_test.go
+++ b/oauth_test.go
@@ -1,251 +1,261 @@
+/*
+ * Copyright © 2019-2021 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"
 	"fmt"
 	"github.com/gorilla/sessions"
 	"github.com/stretchr/testify/assert"
 	"github.com/writeas/impart"
 	"github.com/writeas/nerds/store"
-	"github.com/writeas/writefreely/config"
+	"github.com/writefreely/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(&App{cfg: app.Config(), sessionStore: app.SessionStore()}, rr, req)
 		assert.NoError(t, err)
 		assert.Equal(t, http.StatusTemporaryRedirect, rr.Code)
 	})
 }
diff --git a/pad.go b/pad.go
index 0354cd3..b64c282 100644
--- a/pad.go
+++ b/pad.go
@@ -1,188 +1,188 @@
 /*
- * Copyright © 2018-2019 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"net/http"
 	"strings"
 
 	"github.com/gorilla/mux"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/page"
+	"github.com/writefreely/writefreely/page"
 )
 
 func handleViewPad(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	action := vars["action"]
 	slug := vars["slug"]
 	collAlias := vars["collection"]
 	if app.cfg.App.SingleUser {
 		// TODO: refactor all of this, especially for single-user blogs
 		c, err := app.db.GetCollectionByID(1)
 		if err != nil {
 			return err
 		}
 		collAlias = c.Alias
 	}
 	appData := &struct {
 		page.StaticPage
 		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
 	}{
 		StaticPage: pageForReq(app, r),
 		Post:       &RawPost{Font: "norm"},
 		User:       getUserSession(app, r),
 	}
 	var err error
 	if appData.User != nil {
 		appData.Blogs, err = app.db.GetPublishableCollections(appData.User, app.cfg.App.Host)
 		if err != nil {
 			log.Error("Unable to get user's blogs for Pad: %v", err)
 		}
 		appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
 		if err != nil {
 			log.Error("Unable to get user status for Pad: %v", err)
 		}
 	}
 
 	padTmpl := app.cfg.App.Editor
 	if templates[padTmpl] == nil {
 		if padTmpl != "" {
 			log.Info("No template '%s' found. Falling back to default 'pad' template.", padTmpl)
 		}
 		padTmpl = "pad"
 	}
 
 	if action == "" && slug == "" {
 		// Not editing any post; simply render the Pad
 		if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil {
 			log.Error("Unable to execute template: %v", err)
 		}
 
 		return nil
 	}
 
 	// Retrieve post information for editing
 	appData.Editing = true
 	// Make sure this isn't cached, so user doesn't accidentally lose data
 	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
 	w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT")
 	if slug != "" {
 		// TODO: refactor all of this, especially for single-user blogs
 		appData.Post = getRawCollectionPost(app, slug, collAlias)
 		if appData.Post.OwnerID != appData.User.ID {
 			// TODO: add ErrForbiddenEditPost message to flashes
 			return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/edit")]}
 		}
 		appData.EditCollection, err = app.db.GetCollectionForPad(collAlias)
 		if err != nil {
 			return err
 		}
 		appData.EditCollection.hostName = app.cfg.App.Host
 	} else {
 		// Editing a floating article
 		appData.Post = getRawPost(app, action)
 		appData.Post.Id = action
 	}
 
 	if appData.Post.Gone {
 		return ErrPostUnpublished
 	} else if appData.Post.Found && appData.Post.Content != "" {
 		// Got the post
 	} else if appData.Post.Found {
 		return ErrPostFetchError
 	} else {
 		return ErrPostNotFound
 	}
 
 	if err = templates[padTmpl].ExecuteTemplate(w, "pad", appData); err != nil {
 		log.Error("Unable to execute template: %v", err)
 	}
 	return nil
 }
 
 func handleViewMeta(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	action := vars["action"]
 	slug := vars["slug"]
 	collAlias := vars["collection"]
 	appData := &struct {
 		page.StaticPage
 		Post           *RawPost
 		User           *User
 		EditCollection *Collection // Collection of the post we're editing, if any
 		Flashes        []string
 		NeedsToken     bool
 		Silenced       bool
 	}{
 		StaticPage: pageForReq(app, r),
 		Post:       &RawPost{Font: "norm"},
 		User:       getUserSession(app, r),
 	}
 	var err error
 	appData.Silenced, err = app.db.IsUserSilenced(appData.User.ID)
 	if err != nil {
 		log.Error("view meta: get user status: %v", err)
 		return ErrInternalGeneral
 	}
 
 	if action == "" && slug == "" {
 		return ErrPostNotFound
 	}
 
 	// Make sure this isn't cached, so user doesn't accidentally lose data
 	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
 	w.Header().Set("Expires", "Thu, 28 Jul 1989 12:00:00 GMT")
 	if slug != "" {
 		appData.Post = getRawCollectionPost(app, slug, collAlias)
 		if appData.Post.OwnerID != appData.User.ID {
 			// TODO: add ErrForbiddenEditPost message to flashes
 			return impart.HTTPError{http.StatusFound, r.URL.Path[:strings.LastIndex(r.URL.Path, "/meta")]}
 		}
 		if app.cfg.App.SingleUser {
 			// TODO: optimize this query just like we do in GetCollectionForPad (?)
 			appData.EditCollection, err = app.db.GetCollectionByID(1)
 		} else {
 			appData.EditCollection, err = app.db.GetCollectionForPad(collAlias)
 		}
 		if err != nil {
 			return err
 		}
 		appData.EditCollection.hostName = app.cfg.App.Host
 	} else {
 		// Editing a floating article
 		appData.Post = getRawPost(app, action)
 		appData.Post.Id = action
 	}
 	appData.NeedsToken = appData.User == nil || appData.User.ID != appData.Post.OwnerID
 
 	if appData.Post.Gone {
 		return ErrPostUnpublished
 	} else if appData.Post.Found && appData.Post.Content != "" {
 		// Got the post
 	} else if appData.Post.Found {
 		return ErrPostFetchError
 	} else {
 		return ErrPostNotFound
 	}
 	appData.Flashes, _ = getSessionFlashes(app, w, r, nil)
 
 	if err = templates["edit-meta"].ExecuteTemplate(w, "edit-meta", appData); err != nil {
 		log.Error("Unable to execute template: %v", err)
 	}
 	return nil
 }
diff --git a/page/page.go b/page/page.go
index 15f09a9..2cfb6cc 100644
--- a/page/page.go
+++ b/page/page.go
@@ -1,47 +1,47 @@
 /*
- * Copyright © 2018 A Bunch Tell LLC.
+ * Copyright © 2018-2019, 2021 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 page provides mechanisms and data for generating a WriteFreely page.
 package page
 
 import (
-	"github.com/writeas/writefreely/config"
+	"github.com/writefreely/writefreely/config"
 	"strings"
 )
 
 type StaticPage struct {
 	// App configuration
 	config.AppCfg
 	Version   string
 	HeaderNav bool
 
 	// Request values
 	Path          string
 	Username      string
 	Values        map[string]string
 	Flashes       []string
 	CanViewReader bool
 	IsAdmin       bool
 	CanInvite     bool
 }
 
 // SanitizeHost alters the StaticPage to contain a real hostname. This is
 // especially important for the Tor hidden service, as it can be served over
 // proxies, messing up the apparent hostname.
 func (sp *StaticPage) SanitizeHost(cfg *config.Config) {
 	if cfg.Server.HiddenHost != "" && strings.HasPrefix(sp.Host, cfg.Server.HiddenHost) {
 		sp.Host = cfg.Server.HiddenHost
 	}
 }
 
 func (sp StaticPage) OfficialVersion() string {
 	p := strings.Split(sp.Version, "-")
 	return p[0]
 }
diff --git a/pages.go b/pages.go
index d8f034b..f871882 100644
--- a/pages.go
+++ b/pages.go
@@ -1,164 +1,164 @@
 /*
- * Copyright © 2018-2019 A Bunch Tell LLC.
+ * Copyright © 2018-2019, 2021 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 (
 	"database/sql"
-	"github.com/writeas/writefreely/config"
+	"github.com/writefreely/writefreely/config"
 	"time"
 )
 
 var defaultPageUpdatedTime = time.Date(2018, 11, 8, 12, 0, 0, 0, time.Local)
 
 func getAboutPage(app *App) (*instanceContent, error) {
 	c, err := app.db.GetDynamicContent("about")
 	if err != nil {
 		return nil, err
 	}
 	if c == nil {
 		c = &instanceContent{
 			ID:      "about",
 			Type:    "page",
 			Content: defaultAboutPage(app.cfg),
 		}
 	}
 	if !c.Title.Valid {
 		c.Title = defaultAboutTitle(app.cfg)
 	}
 	return c, nil
 }
 
 func defaultAboutTitle(cfg *config.Config) sql.NullString {
 	return sql.NullString{String: "About " + cfg.App.SiteName, Valid: true}
 }
 
 func getPrivacyPage(app *App) (*instanceContent, error) {
 	c, err := app.db.GetDynamicContent("privacy")
 	if err != nil {
 		return nil, err
 	}
 	if c == nil {
 		c = &instanceContent{
 			ID:      "privacy",
 			Type:    "page",
 			Content: defaultPrivacyPolicy(app.cfg),
 			Updated: defaultPageUpdatedTime,
 		}
 	}
 	if !c.Title.Valid {
 		c.Title = defaultPrivacyTitle()
 	}
 	return c, nil
 }
 
 func defaultPrivacyTitle() sql.NullString {
 	return sql.NullString{String: "Privacy Policy", Valid: true}
 }
 
 func defaultAboutPage(cfg *config.Config) string {
 	if cfg.App.Federation {
 		return `_` + cfg.App.SiteName + `_ is an interconnected place for you to write and publish, powered by [WriteFreely](https://writefreely.org) and ActivityPub.`
 	}
 	return `_` + cfg.App.SiteName + `_ is a place for you to write and publish, powered by [WriteFreely](https://writefreely.org).`
 }
 
 func defaultPrivacyPolicy(cfg *config.Config) string {
 	return `[WriteFreely](https://writefreely.org), the software that powers this site, is built to enforce your right to privacy by default.
 
 It retains as little data about you as possible, not even requiring an email address to sign up. However, if you _do_ give us your email address, it is stored encrypted in our database. We salt and hash your account's password.
 
 We store log files, or data about what happens on our servers. We also use cookies to keep you logged in to your account.
 
 Beyond this, it's important that you trust whoever runs **` + cfg.App.SiteName + `**. Software can only do so much to protect you -- your level of privacy protections will ultimately fall on the humans that run this particular service.`
 }
 
 func getLandingBanner(app *App) (*instanceContent, error) {
 	c, err := app.db.GetDynamicContent("landing-banner")
 	if err != nil {
 		return nil, err
 	}
 	if c == nil {
 		c = &instanceContent{
 			ID:      "landing-banner",
 			Type:    "section",
 			Content: defaultLandingBanner(app.cfg),
 			Updated: defaultPageUpdatedTime,
 		}
 	}
 	return c, nil
 }
 
 func getLandingBody(app *App) (*instanceContent, error) {
 	c, err := app.db.GetDynamicContent("landing-body")
 	if err != nil {
 		return nil, err
 	}
 	if c == nil {
 		c = &instanceContent{
 			ID:      "landing-body",
 			Type:    "section",
 			Content: defaultLandingBody(app.cfg),
 			Updated: defaultPageUpdatedTime,
 		}
 	}
 	return c, nil
 }
 
 func defaultLandingBanner(cfg *config.Config) string {
 	if cfg.App.Federation {
 		return "# Start your blog in the fediverse"
 	}
 	return "# Start your blog"
 }
 
 func defaultLandingBody(cfg *config.Config) string {
 	if cfg.App.Federation {
 		return `## Join the Fediverse
 
 The fediverse is a large network of platforms that all speak a common language. Imagine if you could reply to Instagram posts from Twitter, or interact with your favorite Medium blogs from Facebook -- federated alternatives like [PixelFed](https://pixelfed.org), [Mastodon](https://joinmastodon.org), and WriteFreely enable you to do these types of things.
 
 
 	
 
 
 ## Write More Socially
 
 WriteFreely can communicate with other federated platforms like Mastodon, so people can follow your blogs, bookmark their favorite posts, and boost them to their followers. Sign up above to create a blog and join the fediverse.`
 	}
 	return ""
 }
 
 func getReaderSection(app *App) (*instanceContent, error) {
 	c, err := app.db.GetDynamicContent("reader")
 	if err != nil {
 		return nil, err
 	}
 	if c == nil {
 		c = &instanceContent{
 			ID:      "reader",
 			Type:    "section",
 			Content: defaultReaderBanner(app.cfg),
 			Updated: defaultPageUpdatedTime,
 		}
 	}
 	if !c.Title.Valid {
 		c.Title = defaultReaderTitle(app.cfg)
 	}
 	return c, nil
 }
 
 func defaultReaderTitle(cfg *config.Config) sql.NullString {
 	return sql.NullString{String: "Reader", Valid: true}
 }
 
 func defaultReaderBanner(cfg *config.Config) string {
 	return "Read the latest posts from " + cfg.App.SiteName + "."
 }
diff --git a/pages/500.tmpl b/pages/500.tmpl
index c436280..e148fb5 100644
--- a/pages/500.tmpl
+++ b/pages/500.tmpl
@@ -1,10 +1,10 @@
 {{define "head"}}Server error — {{.SiteName}}{{end}}
 {{define "content"}}
 		
 			Server error 😵
-			
Please contact the human authors of this software and remind them of their many shortcomings.
+			
Please contact the human authors of this software and remind them of their many shortcomings.
 			Be gentle, though. They are fragile mortal beings.
 			Also, unlike the AI that will soon replace them, you will need to include an error log from the server in your report. (Utterly primitive, we know.)
 			– {{.SiteName}} 🤖
 		 
 {{end}}
diff --git a/postrender.go b/postrender.go
index 12c4a81..55d0cdf 100644
--- a/postrender.go
+++ b/postrender.go
@@ -1,308 +1,308 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"encoding/json"
 	"fmt"
 	"html"
 	"html/template"
 	"net/http"
 	"net/url"
 	"regexp"
 	"strings"
 	"unicode"
 	"unicode/utf8"
 
 	"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"
+	"github.com/writefreely/writefreely/config"
+	"github.com/writefreely/writefreely/parse"
 )
 
 var (
 	blockReg        = regexp.MustCompile("<(ul|ol|blockquote)>\n")
 	endBlockReg     = regexp.MustCompile("([a-z]+)>\n(ul|ol|blockquote)>")
 	youtubeReg      = regexp.MustCompile("(https?://www.youtube.com/embed/[a-zA-Z0-9\\-_]+)(\\?[^\t\n\f\r \"']+)?")
 	titleElementReg = regexp.MustCompile("?h[1-6]>")
 	hashtagReg      = regexp.MustCompile(`{{\[\[\|\|([^|]+)\|\|\]\]}}`)
 	markeddownReg   = regexp.MustCompile("(.+)
")
 	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) {
 	baseURL := c.CanonicalURL()
 	// TODO: redundant
 	if !isSingleUser {
 		baseURL = "/" + c.Alias + "/"
 	}
 	p.HTMLTitle = template.HTML(applyBasicMarkdown([]byte(p.Title.String)))
 	p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), baseURL, cfg))
 	if exc := strings.Index(string(p.Content), ""); exc > -1 {
 		p.HTMLExcerpt = template.HTML(applyMarkdown([]byte(p.Content[:exc]), baseURL, cfg))
 	}
 }
 
 func (p *PublicPost) formatContent(cfg *config.Config, isOwner bool) {
 	p.Post.formatContent(cfg, &p.Collection.Collection, isOwner)
 }
 
 func (p *Post) augmentContent(c *Collection) {
 	if p.PinnedPosition.Valid {
 		// Don't augment posts that are pinned
 		return
 	}
 	if strings.Index(p.Content, "") > -1 {
 		// Don't augment posts with the special "nosig" shortcode
 		return
 	}
 	// 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)
 }
 
 func disableYoutubeAutoplay(outHTML string) string {
 	for _, match := range youtubeReg.FindAllString(outHTML, -1) {
 		u, err := url.Parse(match)
 		if err != nil {
 			continue
 		}
 		u.RawQuery = html.UnescapeString(u.RawQuery)
 		q := u.Query()
 		// Set Youtube autoplay url parameter, if any, to 0
 		if len(q["autoplay"]) == 1 {
 			q.Set("autoplay", "0")
 		}
 		u.RawQuery = q.Encode()
 		cleanURL := u.String()
 		outHTML = strings.Replace(outHTML, match, cleanURL, 1)
 	}
 	return outHTML
 }
 
 func applyMarkdownSpecial(data []byte, skipNoFollow bool, baseURL string, cfg *config.Config) string {
 	mdExtensions := 0 |
 		blackfriday.EXTENSION_TABLES |
 		blackfriday.EXTENSION_FENCED_CODE |
 		blackfriday.EXTENSION_AUTOLINK |
 		blackfriday.EXTENSION_STRIKETHROUGH |
 		blackfriday.EXTENSION_SPACE_HEADERS |
 		blackfriday.EXTENSION_AUTO_HEADER_IDS
 	htmlFlags := 0 |
 		blackfriday.HTML_USE_SMARTYPANTS |
 		blackfriday.HTML_SMARTYPANTS_DASHES
 
 	if baseURL != "" {
 		htmlFlags |= blackfriday.HTML_HASHTAGS
 	}
 
 	// Generate Markdown
 	md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
 	if baseURL != "" {
 		// Replace special text generated by Markdown parser
 		tagPrefix := baseURL + "tag:"
 		if cfg.App.Chorus {
 			tagPrefix = "/read/t/"
 		}
 		md = []byte(hashtagReg.ReplaceAll(md, []byte("#$1")))
 		handlePrefix := cfg.App.Host + "/@/"
 		md = []byte(mentionReg.ReplaceAll(md, []byte("@$1$2")))
 	}
 	// Strip out bad HTML
 	policy := getSanitizationPolicy()
 	policy.RequireNoFollowOnLinks(!skipNoFollow)
 	outHTML := string(policy.SanitizeBytes(md))
 	// Strip newlines on certain block elements that render with them
 	outHTML = blockReg.ReplaceAllString(outHTML, "<$1>")
 	outHTML = endBlockReg.ReplaceAllString(outHTML, "$1>$2>")
 	outHTML = disableYoutubeAutoplay(outHTML)
 	return outHTML
 }
 
 func applyBasicMarkdown(data []byte) string {
 	mdExtensions := 0 |
 		blackfriday.EXTENSION_STRIKETHROUGH |
 		blackfriday.EXTENSION_SPACE_HEADERS |
 		blackfriday.EXTENSION_HEADER_IDS
 	htmlFlags := 0 |
 		blackfriday.HTML_SKIP_HTML |
 		blackfriday.HTML_USE_SMARTYPANTS |
 		blackfriday.HTML_SMARTYPANTS_DASHES
 
 	// Generate Markdown
 	md := blackfriday.Markdown([]byte(data), blackfriday.HtmlRenderer(htmlFlags, "", ""), mdExtensions)
 	// Strip out bad HTML
 	policy := bluemonday.UGCPolicy()
 	policy.AllowAttrs("class", "id").Globally()
 	outHTML := string(policy.SanitizeBytes(md))
 	outHTML = markeddownReg.ReplaceAllString(outHTML, "$1")
 	outHTML = strings.TrimRightFunc(outHTML, unicode.IsSpace)
 
 	return outHTML
 }
 
 func postTitle(content, friendlyId string) string {
 	const maxTitleLen = 80
 
 	content = stripHTMLWithoutEscaping(content)
 
 	content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
 	eol := strings.IndexRune(content, '\n')
 	blankLine := strings.Index(content, "\n\n")
 	if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
 		return strings.TrimSpace(content[:blankLine])
 	} else if utf8.RuneCountInString(content) <= maxTitleLen {
 		return content
 	}
 	return friendlyId
 }
 
 // TODO: fix duplicated code from postTitle. postTitle is a widely used func we
 // don't have time to investigate right now.
 func friendlyPostTitle(content, friendlyId string) string {
 	const maxTitleLen = 80
 
 	content = stripHTMLWithoutEscaping(content)
 
 	content = strings.TrimLeftFunc(stripmd.Strip(content), unicode.IsSpace)
 	eol := strings.IndexRune(content, '\n')
 	blankLine := strings.Index(content, "\n\n")
 	if blankLine != -1 && blankLine <= eol && blankLine <= assumedTitleLen {
 		return strings.TrimSpace(content[:blankLine])
 	} else if eol == -1 && utf8.RuneCountInString(content) <= maxTitleLen {
 		return content
 	}
 	title, truncd := parse.TruncToWord(parse.PostLede(content, true), maxTitleLen)
 	if truncd {
 		title += "..."
 	}
 	return title
 }
 
 // Strip HTML tags with bluemonday's StrictPolicy, then unescape the HTML
 // entities added in by sanitizing the content.
 func stripHTMLWithoutEscaping(content string) string {
 	return html.UnescapeString(bluemonday.StrictPolicy().Sanitize(content))
 }
 
 func getSanitizationPolicy() *bluemonday.Policy {
 	policy := bluemonday.UGCPolicy()
 	policy.AllowAttrs("src", "style").OnElements("iframe", "video", "audio")
 	policy.AllowAttrs("src", "type").OnElements("source")
 	policy.AllowAttrs("frameborder", "width", "height").Matching(bluemonday.Integer).OnElements("iframe")
 	policy.AllowAttrs("allowfullscreen").OnElements("iframe")
 	policy.AllowAttrs("controls", "loop", "muted", "autoplay").OnElements("video")
 	policy.AllowAttrs("controls", "loop", "muted", "autoplay", "preload").OnElements("audio")
 	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
 }
 
 func sanitizePost(content string) string {
 	return strings.Replace(content, "<", "<", -1)
 }
 
 // postDescription generates a description based on the given post content,
 // title, and post ID. This doesn't consider a V2 post field, `title` when
 // choosing what to generate. In case a post has a title, this function will
 // fail, and logic should instead be implemented to skip this when there's no
 // title, like so:
 //    var desc string
 //    if title == "" {
 //        desc = postDescription(content, title, friendlyId)
 //    } else {
 //        desc = shortPostDescription(content)
 //    }
 func postDescription(content, title, friendlyId string) string {
 	maxLen := 140
 
 	if content == "" {
 		content = "WriteFreely is a painless, simple, federated blogging platform."
 	} else {
 		fmtStr := "%s"
 		truncation := 0
 		if utf8.RuneCountInString(content) > maxLen {
 			// Post is longer than the max description, so let's show a better description
 			fmtStr = "%s..."
 			truncation = 3
 		}
 
 		if title == friendlyId {
 			// No specific title was found; simply truncate the post, starting at the beginning
 			content = fmt.Sprintf(fmtStr, strings.Replace(stringmanip.Substring(content, 0, maxLen-truncation), "\n", " ", -1))
 		} else {
 			// There was a title, so return a real description
 			blankLine := strings.Index(content, "\n\n")
 			if blankLine < 0 {
 				blankLine = 0
 			}
 			truncd := stringmanip.Substring(content, blankLine, blankLine+maxLen-truncation)
 			contentNoNL := strings.Replace(truncd, "\n", " ", -1)
 			content = strings.TrimSpace(fmt.Sprintf(fmtStr, contentNoNL))
 		}
 	}
 
 	return content
 }
 
 func shortPostDescription(content string) string {
 	maxLen := 140
 	fmtStr := "%s"
 	truncation := 0
 	if utf8.RuneCountInString(content) > maxLen {
 		// Post is longer than the max description, so let's show a better description
 		fmtStr = "%s..."
 		truncation = 3
 	}
 	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)
 }
diff --git a/posts.go b/posts.go
index 383da89..e07bb80 100644
--- a/posts.go
+++ b/posts.go
@@ -1,1597 +1,1597 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"database/sql"
 	"encoding/json"
 	"fmt"
 	"html/template"
 	"net/http"
 	"net/url"
 	"regexp"
 	"strings"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/guregu/null"
 	"github.com/guregu/null/zero"
 	"github.com/kylemcc/twitter-text-go/extract"
 	"github.com/microcosm-cc/bluemonday"
 	stripmd "github.com/writeas/go-strip-markdown"
 	"github.com/writeas/impart"
 	"github.com/writeas/monday"
 	"github.com/writeas/slug"
 	"github.com/writeas/web-core/activitystreams"
 	"github.com/writeas/web-core/bots"
 	"github.com/writeas/web-core/converter"
 	"github.com/writeas/web-core/i18n"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/web-core/tags"
-	"github.com/writeas/writefreely/page"
-	"github.com/writeas/writefreely/parse"
+	"github.com/writefreely/writefreely/page"
+	"github.com/writefreely/writefreely/parse"
 )
 
 const (
 	// Post ID length bounds
 	minIDLen      = 10
 	maxIDLen      = 10
 	userPostIDLen = 10
 	postIDLen     = 10
 
 	postMetaDateFormat = "2006-01-02 15:04:05"
 )
 
 type (
 	AnonymousPost struct {
 		ID          string
 		Content     string
 		HTMLContent template.HTML
 		Font        string
 		Language    string
 		Direction   string
 		Title       string
 		GenTitle    string
 		Description string
 		Author      string
 		Views       int64
 		Images      []string
 		IsPlainText bool
 		IsCode      bool
 		IsLinkable  bool
 	}
 
 	AuthenticatedPost struct {
 		ID  string `json:"id" schema:"id"`
 		Web bool   `json:"web" schema:"web"`
 		*SubmittedPost
 	}
 
 	// SubmittedPost represents a post supplied by a client for publishing or
 	// updating. Since Title and Content can be updated to "", they are
 	// pointers that can be easily tested to detect changes.
 	SubmittedPost struct {
 		Slug     *string                  `json:"slug" schema:"slug"`
 		Title    *string                  `json:"title" schema:"title"`
 		Content  *string                  `json:"body" schema:"body"`
 		Font     string                   `json:"font" schema:"font"`
 		IsRTL    converter.NullJSONBool   `json:"rtl" schema:"rtl"`
 		Language converter.NullJSONString `json:"lang" schema:"lang"`
 		Created  *string                  `json:"created" schema:"created"`
 	}
 
 	// Post represents a post as found in the database.
 	Post struct {
 		ID             string        `db:"id" json:"id"`
 		Slug           null.String   `db:"slug" json:"slug,omitempty"`
 		Font           string        `db:"text_appearance" json:"appearance"`
 		Language       zero.String   `db:"language" json:"language"`
 		RTL            zero.Bool     `db:"rtl" json:"rtl"`
 		Privacy        int64         `db:"privacy" json:"-"`
 		OwnerID        null.Int      `db:"owner_id" json:"-"`
 		CollectionID   null.Int      `db:"collection_id" json:"-"`
 		PinnedPosition null.Int      `db:"pinned_position" json:"-"`
 		Created        time.Time     `db:"created" json:"created"`
 		Updated        time.Time     `db:"updated" json:"updated"`
 		ViewCount      int64         `db:"view_count" json:"-"`
 		Title          zero.String   `db:"title" json:"title"`
 		HTMLTitle      template.HTML `db:"title" json:"-"`
 		Content        string        `db:"content" json:"body"`
 		HTMLContent    template.HTML `db:"content" json:"-"`
 		HTMLExcerpt    template.HTML `db:"content" json:"-"`
 		Tags           []string      `json:"tags"`
 		Images         []string      `json:"images,omitempty"`
 
 		OwnerName string `json:"owner,omitempty"`
 	}
 
 	// PublicPost holds properties for a publicly returned post, i.e. a post in
 	// a context where the viewer may not be the owner. As such, sensitive
 	// metadata for the post is hidden and properties supporting the display of
 	// the post are added.
 	PublicPost struct {
 		*Post
 		IsSubdomain bool           `json:"-"`
 		IsTopLevel  bool           `json:"-"`
 		DisplayDate string         `json:"-"`
 		Views       int64          `json:"views"`
 		Owner       *PublicUser    `json:"-"`
 		IsOwner     bool           `json:"-"`
 		Collection  *CollectionObj `json:"collection,omitempty"`
 	}
 
 	RawPost struct {
 		Id, Slug     string
 		Title        string
 		Content      string
 		Views        int64
 		Font         string
 		Created      time.Time
 		Updated      time.Time
 		IsRTL        sql.NullBool
 		Language     sql.NullString
 		OwnerID      int64
 		CollectionID sql.NullInt64
 
 		Found bool
 		Gone  bool
 	}
 
 	AnonymousAuthPost struct {
 		ID    string `json:"id"`
 		Token string `json:"token"`
 	}
 	ClaimPostRequest struct {
 		*AnonymousAuthPost
 		CollectionAlias  string `json:"collection"`
 		CreateCollection bool   `json:"create_collection"`
 
 		// Generated properties
 		Slug string `json:"-"`
 	}
 	ClaimPostResult struct {
 		ID           string      `json:"id,omitempty"`
 		Code         int         `json:"code,omitempty"`
 		ErrorMessage string      `json:"error_msg,omitempty"`
 		Post         *PublicPost `json:"post,omitempty"`
 	}
 )
 
 func (p *Post) Direction() string {
 	if p.RTL.Valid {
 		if p.RTL.Bool {
 			return "rtl"
 		}
 		return "ltr"
 	}
 	return "auto"
 }
 
 // DisplayTitle dynamically generates a title from the Post's contents if it
 // doesn't already have an explicit title.
 func (p *Post) DisplayTitle() string {
 	if p.Title.String != "" {
 		return p.Title.String
 	}
 	t := friendlyPostTitle(p.Content, p.ID)
 	return t
 }
 
 // PlainDisplayTitle dynamically generates a title from the Post's contents if it
 // doesn't already have an explicit title.
 func (p *Post) PlainDisplayTitle() string {
 	if t := stripmd.Strip(p.DisplayTitle()); t != "" {
 		return t
 	}
 	return p.ID
 }
 
 // FormattedDisplayTitle dynamically generates a title from the Post's contents if it
 // doesn't already have an explicit title.
 func (p *Post) FormattedDisplayTitle() template.HTML {
 	if p.HTMLTitle != "" {
 		return p.HTMLTitle
 	}
 	return template.HTML(p.DisplayTitle())
 }
 
 // Summary gives a shortened summary of the post based on the post's title,
 // especially for display in a longer list of posts. It extracts a summary for
 // posts in the Title\n\nBody format, returning nothing if the entire was short
 // enough that the extracted title == extracted summary.
 func (p Post) Summary() string {
 	if p.Content == "" {
 		return ""
 	}
 	p.Content = stripHTMLWithoutEscaping(p.Content)
 	// and Markdown
 	p.Content = stripmd.Strip(p.Content)
 
 	title := p.Title.String
 	var desc string
 	if title == "" {
 		// No title, so generate one
 		title = friendlyPostTitle(p.Content, p.ID)
 		desc = postDescription(p.Content, title, p.ID)
 		if desc == title {
 			return ""
 		}
 		return desc
 	}
 
 	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 {
 	return p.HTMLExcerpt
 }
 
 func (p *Post) CreatedDate() string {
 	return p.Created.Format("2006-01-02")
 }
 
 func (p *Post) Created8601() string {
 	return p.Created.Format("2006-01-02T15:04:05Z")
 }
 
 func (p *Post) IsScheduled() bool {
 	return p.Created.After(time.Now())
 }
 
 func (p *Post) HasTag(tag string) bool {
 	// Regexp looks for tag and has a non-capturing group at the end looking
 	// for the end of the word.
 	// Assisted by: https://stackoverflow.com/a/35192941/1549194
 	hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content)
 	return hasTag
 }
 
 func (p *Post) HasTitleLink() bool {
 	if p.Title.String == "" {
 		return false
 	}
 	hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String)
 	return hasLink
 }
 
 func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	friendlyID := vars["post"]
 
 	// NOTE: until this is done better, be sure to keep this in parity with
 	// isRaw() and viewCollectionPost()
 	isJSON := strings.HasSuffix(friendlyID, ".json")
 	isXML := strings.HasSuffix(friendlyID, ".xml")
 	isCSS := strings.HasSuffix(friendlyID, ".css")
 	isMarkdown := strings.HasSuffix(friendlyID, ".md")
 	isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown
 
 	// Display reserved page if that is requested resource
 	if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
 		return handleTemplatedPage(app, w, r, t)
 	} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
 		// Serve static file
 		app.shttp.ServeHTTP(w, r)
 		return nil
 	}
 
 	// Display collection if this is a collection
 	c, _ := app.db.GetCollection(friendlyID)
 	if c != nil {
 		return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)}
 	}
 
 	// Normalize the URL, redirecting user to consistent post URL
 	if friendlyID != strings.ToLower(friendlyID) {
 		return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))}
 	}
 
 	ext := ""
 	if isRaw {
 		parts := strings.Split(friendlyID, ".")
 		friendlyID = parts[0]
 		if len(parts) > 1 {
 			ext = "." + parts[1]
 		}
 	}
 
 	var ownerID sql.NullInt64
 	var title string
 	var content string
 	var font string
 	var language []byte
 	var rtl []byte
 	var views int64
 	var post *AnonymousPost
 	var found bool
 	var gone bool
 
 	fixedID := slug.Make(friendlyID)
 	if fixedID != friendlyID {
 		return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
 	}
 
 	err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl)
 	switch {
 	case err == sql.ErrNoRows:
 		found = false
 
 		// Output the error in the correct format
 		if isJSON {
 			content = "{\"error\": \"Post not found.\"}"
 		} else if isRaw {
 			content = "Post not found."
 		} else {
 			return ErrPostNotFound
 		}
 	case err != nil:
 		found = false
 
 		log.Error("Post loading err: %s\n", err)
 		return ErrInternalGeneral
 	default:
 		found = true
 
 		var d string
 		if len(rtl) == 0 {
 			d = "auto"
 		} else if rtl[0] == 49 {
 			// TODO: find a cleaner way to get this (possibly NULL) value
 			d = "rtl"
 		} else {
 			d = "ltr"
 		}
 		generatedTitle := friendlyPostTitle(content, friendlyID)
 		sanitizedContent := content
 		if font != "code" {
 			sanitizedContent = template.HTMLEscapeString(content)
 		}
 		var desc string
 		if title == "" {
 			desc = postDescription(content, title, friendlyID)
 		} else {
 			desc = shortPostDescription(content)
 		}
 		post = &AnonymousPost{
 			ID:          friendlyID,
 			Content:     sanitizedContent,
 			Title:       title,
 			GenTitle:    generatedTitle,
 			Description: desc,
 			Author:      "",
 			Font:        font,
 			IsPlainText: isRaw,
 			IsCode:      font == "code",
 			IsLinkable:  font != "code",
 			Views:       views,
 			Language:    string(language),
 			Direction:   d,
 		}
 		if !isRaw {
 			post.HTMLContent = template.HTML(applyMarkdown([]byte(content), "", app.cfg))
 			post.Images = extractImages(post.Content)
 		}
 	}
 
 	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
 	if content == "" {
 		gone = true
 
 		if isJSON {
 			content = "{\"error\": \"Post was unpublished.\"}"
 		} else if isCSS {
 			content = ""
 		} else if isRaw {
 			content = "Post was unpublished."
 		} else {
 			return ErrPostUnpublished
 		}
 	}
 
 	var u = &User{}
 	if isRaw {
 		contentType := "text/plain"
 		if isJSON {
 			contentType = "application/json"
 		} else if isCSS {
 			contentType = "text/css"
 		} else if isXML {
 			contentType = "application/xml"
 		} else if isMarkdown {
 			contentType = "text/markdown"
 		}
 		w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
 		if isMarkdown && post.Title != "" {
 			fmt.Fprintf(w, "%s\n", post.Title)
 			for i := 1; i <= len(post.Title); i++ {
 				fmt.Fprintf(w, "=")
 			}
 			fmt.Fprintf(w, "\n\n")
 		}
 		fmt.Fprint(w, content)
 
 		if !found {
 			return ErrPostNotFound
 		} else if gone {
 			return ErrPostUnpublished
 		}
 	} else {
 		var err error
 		page := struct {
 			*AnonymousPost
 			page.StaticPage
 			Username string
 			IsOwner  bool
 			SiteURL  string
 			Silenced bool
 		}{
 			AnonymousPost: post,
 			StaticPage:    pageForReq(app, r),
 			SiteURL:       app.cfg.App.Host,
 		}
 		if u = getUserSession(app, r); u != nil {
 			page.Username = u.Username
 			page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
 		}
 
 		if !page.IsOwner && silenced {
 			return ErrPostNotFound
 		}
 		page.Silenced = silenced
 		err = templates["post"].ExecuteTemplate(w, "post", page)
 		if err != nil {
 			log.Error("Post template execute error: %v", err)
 		}
 	}
 
 	go func() {
 		if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
 			// Post is owned by someone; skip view increment since that person is viewing this post.
 			return
 		}
 		// Update stats for non-raw post views
 		if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
 			_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
 			if err != nil {
 				log.Error("Unable to update posts count: %v", err)
 			}
 		}
 	}()
 
 	return nil
 }
 
 // API v2 funcs
 // newPost creates a new post with or without an owning Collection.
 //
 // Endpoints:
 //   /posts
 //   /posts?collection={alias}
 // ? /collections/{alias}/posts
 func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	vars := mux.Vars(r)
 	collAlias := vars["alias"]
 	if collAlias == "" {
 		collAlias = r.FormValue("collection")
 	}
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" {
 		// TODO: remove this
 		accessToken = r.FormValue("access_token")
 	}
 
 	// FIXME: determine web submission with Content-Type header
 	var u *User
 	var userID int64 = -1
 	var username string
 	if accessToken == "" {
 		u = getUserSession(app, r)
 		if u != nil {
 			userID = u.ID
 			username = u.Username
 		}
 	} else {
 		userID = app.db.GetUserID(accessToken)
 	}
 	silenced, err := app.db.IsUserSilenced(userID)
 	if err != nil {
 		log.Error("new post: %v", err)
 	}
 	if silenced {
 		return ErrUserSilenced
 	}
 
 	if userID == -1 {
 		return ErrNotLoggedIn
 	}
 
 	if accessToken == "" && u == nil && collAlias != "" {
 		return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
 	}
 
 	// Get post data
 	var p *SubmittedPost
 	if reqJSON {
 		decoder := json.NewDecoder(r.Body)
 		err = decoder.Decode(&p)
 		if err != nil {
 			log.Error("Couldn't parse new post JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 		if p.Title == nil {
 			t := ""
 			p.Title = &t
 		}
 		if strings.TrimSpace(*(p.Content)) == "" {
 			return ErrNoPublishableContent
 		}
 	} else {
 		post := r.FormValue("body")
 		appearance := r.FormValue("font")
 		title := r.FormValue("title")
 		rtlValue := r.FormValue("rtl")
 		langValue := r.FormValue("lang")
 		if strings.TrimSpace(post) == "" {
 			return ErrNoPublishableContent
 		}
 
 		var isRTL, rtlValid bool
 		if rtlValue == "auto" && langValue != "" {
 			isRTL = i18n.LangIsRTL(langValue)
 			rtlValid = true
 		} else {
 			isRTL = rtlValue == "true"
 			rtlValid = rtlValue != "" && langValue != ""
 		}
 
 		// Create a new post
 		p = &SubmittedPost{
 			Title:    &title,
 			Content:  &post,
 			Font:     appearance,
 			IsRTL:    converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
 			Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
 		}
 	}
 	if !p.isFontValid() {
 		p.Font = "norm"
 	}
 
 	var newPost *PublicPost = &PublicPost{}
 	var coll *Collection
 	if accessToken != "" {
 		newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias, app.cfg.App.Host)
 	} else {
 		//return ErrNotLoggedIn
 		// TODO: verify user is logged in
 		var collID int64
 		if collAlias != "" {
 			coll, err = app.db.GetCollection(collAlias)
 			if err != nil {
 				return err
 			}
 			coll.hostName = app.cfg.App.Host
 			if coll.OwnerID != u.ID {
 				return ErrForbiddenCollection
 			}
 			collID = coll.ID
 		}
 		// TODO: return PublicPost from createPost
 		newPost.Post, err = app.db.CreatePost(userID, collID, p)
 	}
 	if err != nil {
 		return err
 	}
 	if coll != nil {
 		coll.ForPublic()
 		newPost.Collection = &CollectionObj{Collection: *coll}
 	}
 
 	newPost.extractData()
 	newPost.OwnerName = username
 
 	// Write success now
 	response := impart.WriteSuccess(w, newPost, http.StatusCreated)
 
 	if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
 		go federatePost(app, newPost, newPost.Collection.ID, false)
 	}
 
 	return response
 }
 
 func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r)
 	vars := mux.Vars(r)
 	postID := vars["post"]
 
 	p := AuthenticatedPost{ID: postID}
 	var err error
 
 	if reqJSON {
 		// Decode JSON request
 		decoder := json.NewDecoder(r.Body)
 		err = decoder.Decode(&p)
 		if err != nil {
 			log.Error("Couldn't parse post update JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 	} else {
 		err = r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse post update form request: %v\n", err)
 			return ErrBadFormData
 		}
 
 		// Can't decode to a nil SubmittedPost property, so create instance now
 		p.SubmittedPost = &SubmittedPost{}
 		err = app.formDecoder.Decode(&p, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode post update form request: %v\n", err)
 			return ErrBadFormData
 		}
 	}
 
 	if p.Web {
 		p.IsRTL.Valid = true
 	}
 
 	if p.SubmittedPost == nil {
 		return ErrPostNoUpdatableVals
 	}
 
 	// Ensure an access token was given
 	accessToken := r.Header.Get("Authorization")
 	// Get user's cookie session if there's no token
 	var u *User
 	//var username string
 	if accessToken == "" {
 		u = getUserSession(app, r)
 		if u != nil {
 			//username = u.Username
 		}
 	}
 	if u == nil && accessToken == "" {
 		return ErrNoAccessToken
 	}
 
 	// Get user ID from current session or given access token, if one was given.
 	var userID int64
 	if u != nil {
 		userID = u.ID
 	} else if accessToken != "" {
 		userID, err = AuthenticateUser(app.db, accessToken)
 		if err != nil {
 			return err
 		}
 	}
 
 	silenced, err := app.db.IsUserSilenced(userID)
 	if err != nil {
 		log.Error("existing post: %v", err)
 	}
 	if silenced {
 		return ErrUserSilenced
 	}
 
 	// Modify post struct
 	p.ID = postID
 
 	err = app.db.UpdateOwnedPost(&p, userID)
 	if err != nil {
 		if reqJSON {
 			return err
 		}
 
 		if err, ok := err.(impart.HTTPError); ok {
 			addSessionFlash(app, w, r, err.Message, nil)
 		} else {
 			addSessionFlash(app, w, r, err.Error(), nil)
 		}
 	}
 
 	var pRes *PublicPost
 	pRes, err = app.db.GetPost(p.ID, 0)
 	if reqJSON {
 		if err != nil {
 			return err
 		}
 		pRes.extractData()
 	}
 
 	if pRes.CollectionID.Valid {
 		coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
 		if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
 			coll.hostName = app.cfg.App.Host
 			pRes.Collection = &CollectionObj{Collection: *coll}
 			go federatePost(app, pRes, pRes.Collection.ID, true)
 		}
 	}
 
 	// Write success now
 	if reqJSON {
 		return impart.WriteSuccess(w, pRes, http.StatusOK)
 	}
 
 	addSessionFlash(app, w, r, "Changes saved.", nil)
 	collectionAlias := vars["alias"]
 	redirect := "/" + postID + "/meta"
 	if collectionAlias != "" {
 		collPre := "/" + collectionAlias
 		if app.cfg.App.SingleUser {
 			collPre = ""
 		}
 		redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
 	} else {
 		if app.cfg.App.SingleUser {
 			redirect = "/d" + redirect
 		}
 	}
 	w.Header().Set("Location", redirect)
 	w.WriteHeader(http.StatusFound)
 
 	return nil
 }
 
 func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	friendlyID := vars["post"]
 	editToken := r.FormValue("token")
 
 	var ownerID int64
 	var u *User
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" && editToken == "" {
 		u = getUserSession(app, r)
 		if u == nil {
 			return ErrNoAccessToken
 		}
 	}
 
 	var res sql.Result
 	var t *sql.Tx
 	var err error
 	var collID sql.NullInt64
 	var coll *Collection
 	var pp *PublicPost
 	if editToken != "" {
 		// TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
 		var dummy int64
 		err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
 		switch {
 		case err == sql.ErrNoRows:
 			return impart.HTTPError{http.StatusNotFound, "Post not found."}
 		}
 		err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
 		switch {
 		case err == sql.ErrNoRows:
 			// Post already has an owner. This could provide a bad experience
 			// for the user, but it's more important to ensure data isn't lost
 			// unexpectedly. So prevent deletion via token.
 			return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
 		}
 		res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
 	} else if accessToken != "" || u != nil {
 		// Caller provided some way to authenticate; assume caller expects the
 		// post to be deleted based on a specific post owner, thus we should
 		// return corresponding errors.
 		if accessToken != "" {
 			ownerID = app.db.GetUserID(accessToken)
 			if ownerID == -1 {
 				return ErrBadAccessToken
 			}
 		} else {
 			ownerID = u.ID
 		}
 
 		// TODO: don't make two queries
 		var realOwnerID sql.NullInt64
 		err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
 		if err != nil {
 			return err
 		}
 		if !collID.Valid {
 			// There's no collection; simply delete the post
 			res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
 		} else {
 			// Post belongs to a collection; do any additional clean up
 			coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
 			if err != nil {
 				log.Error("Unable to get collection: %v", err)
 				return err
 			}
 			if app.cfg.App.Federation {
 				// First fetch full post for federation
 				pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
 				if err != nil {
 					log.Error("Unable to get owned post: %v", err)
 					return err
 				}
 				collObj := &CollectionObj{Collection: *coll}
 				pp.Collection = collObj
 			}
 
 			t, err = app.db.Begin()
 			if err != nil {
 				log.Error("No begin: %v", err)
 				return err
 			}
 			res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
 		}
 	} else {
 		return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
 	}
 	if err != nil {
 		return err
 	}
 
 	affected, err := res.RowsAffected()
 	if err != nil {
 		if t != nil {
 			t.Rollback()
 			log.Error("Rows affected err! Rolling back")
 		}
 		return err
 	} else if affected == 0 {
 		if t != nil {
 			t.Rollback()
 			log.Error("No rows affected! Rolling back")
 		}
 		return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
 	}
 	if t != nil {
 		t.Commit()
 	}
 	if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
 		go deleteFederatedPost(app, pp, collID.Int64)
 	}
 
 	return impart.HTTPError{Status: http.StatusNoContent}
 }
 
 // addPost associates a post with the authenticated user.
 func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var ownerID int64
 
 	// Authenticate user
 	at := r.Header.Get("Authorization")
 	if at != "" {
 		ownerID = app.db.GetUserID(at)
 		if ownerID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 		ownerID = u.ID
 	}
 
 	silenced, err := app.db.IsUserSilenced(ownerID)
 	if err != nil {
 		log.Error("add post: %v", err)
 	}
 	if silenced {
 		return ErrUserSilenced
 	}
 
 	// Parse claimed posts in format:
 	// [{"id": "...", "token": "..."}]
 	var claims *[]ClaimPostRequest
 	decoder := json.NewDecoder(r.Body)
 	err = decoder.Decode(&claims)
 	if err != nil {
 		return ErrBadJSONArray
 	}
 
 	vars := mux.Vars(r)
 	collAlias := vars["alias"]
 
 	// Update all given posts
 	res, err := app.db.ClaimPosts(app.cfg, ownerID, collAlias, claims)
 	if err != nil {
 		return err
 	}
 
 	if !app.cfg.App.Private && app.cfg.App.Federation {
 		for _, pRes := range *res {
 			if pRes.Code != http.StatusOK {
 				continue
 			}
 			if !pRes.Post.Created.After(time.Now()) {
 				pRes.Post.Collection.hostName = app.cfg.App.Host
 				go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
 			}
 		}
 	}
 	return impart.WriteSuccess(w, res, http.StatusOK)
 }
 
 func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var ownerID int64
 
 	// Authenticate user
 	at := r.Header.Get("Authorization")
 	if at != "" {
 		ownerID = app.db.GetUserID(at)
 		if ownerID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 		ownerID = u.ID
 	}
 
 	// Parse posts in format:
 	// ["..."]
 	var postIDs []string
 	decoder := json.NewDecoder(r.Body)
 	err := decoder.Decode(&postIDs)
 	if err != nil {
 		return ErrBadJSONArray
 	}
 
 	// Update all given posts
 	res, err := app.db.DispersePosts(ownerID, postIDs)
 	if err != nil {
 		return err
 	}
 	return impart.WriteSuccess(w, res, http.StatusOK)
 }
 
 type (
 	PinPostResult struct {
 		ID           string `json:"id,omitempty"`
 		Code         int    `json:"code,omitempty"`
 		ErrorMessage string `json:"error_msg,omitempty"`
 	}
 )
 
 // pinPost pins a post to a blog
 func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var userID int64
 
 	// Authenticate user
 	at := r.Header.Get("Authorization")
 	if at != "" {
 		userID = app.db.GetUserID(at)
 		if userID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 		userID = u.ID
 	}
 
 	silenced, err := app.db.IsUserSilenced(userID)
 	if err != nil {
 		log.Error("pin post: %v", err)
 	}
 	if silenced {
 		return ErrUserSilenced
 	}
 
 	// Parse request
 	var posts []struct {
 		ID       string `json:"id"`
 		Position int64  `json:"position"`
 	}
 	decoder := json.NewDecoder(r.Body)
 	err = decoder.Decode(&posts)
 	if err != nil {
 		return ErrBadJSONArray
 	}
 
 	// Validate data
 	vars := mux.Vars(r)
 	collAlias := vars["alias"]
 
 	coll, err := app.db.GetCollection(collAlias)
 	if err != nil {
 		return err
 	}
 	if coll.OwnerID != userID {
 		return ErrForbiddenCollection
 	}
 
 	// Do (un)pinning
 	isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
 	res := []PinPostResult{}
 	for _, p := range posts {
 		err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
 		ppr := PinPostResult{ID: p.ID}
 		if err != nil {
 			ppr.Code = http.StatusInternalServerError
 			// TODO: set error messsage
 		} else {
 			ppr.Code = http.StatusOK
 		}
 		res = append(res, ppr)
 	}
 	return impart.WriteSuccess(w, res, http.StatusOK)
 }
 
 func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var collID int64
 	var coll *Collection
 	var err error
 	vars := mux.Vars(r)
 	if collAlias := vars["alias"]; collAlias != "" {
 		// Fetch collection information, since an alias is provided
 		coll, err = app.db.GetCollection(collAlias)
 		if err != nil {
 			return err
 		}
 		collID = coll.ID
 	}
 
 	p, err := app.db.GetPost(vars["post"], collID)
 	if err != nil {
 		return err
 	}
 	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
 		}
 	}
 
 	silenced, err := app.db.IsUserSilenced(p.OwnerID.Int64)
 	if err != nil {
 		log.Error("fetch post: %v", err)
 	}
 	if silenced {
 		return ErrPostNotFound
 	}
 
 	p.extractData()
 
 	accept := r.Header.Get("Accept")
 	if strings.Contains(accept, "application/activity+json") {
 		if coll == nil {
 			// This is a draft post; 404 for now
 			// TODO: return ActivityObject
 			return impart.HTTPError{http.StatusNotFound, ""}
 		}
 
 		p.Collection = &CollectionObj{Collection: *coll}
 		po := p.ActivityObject(app)
 		po.Context = []interface{}{activitystreams.Namespace}
 		setCacheControl(w, apCacheTime)
 		return impart.RenderActivityJSON(w, po, http.StatusOK)
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
 	if err != nil {
 		return err
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func (p *Post) processPost() PublicPost {
 	res := &PublicPost{Post: p, Views: 0}
 	res.Views = p.ViewCount
 	// TODO: move to own function
 	loc := monday.FuzzyLocale(p.Language.String)
 	res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
 
 	return *res
 }
 
 func (p *PublicPost) CanonicalURL(hostName string) string {
 	if p.Collection == nil || p.Collection.Alias == "" {
 		return hostName + "/" + p.ID
 	}
 	return p.Collection.CanonicalURL() + p.Slug.String
 }
 
 func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
 	cfg := app.cfg
 	var o *activitystreams.Object
 	if cfg.App.NotesOnly || strings.Index(p.Content, "\n\n") == -1 {
 		o = activitystreams.NewNoteObject()
 	} else {
 		o = activitystreams.NewArticleObject()
 	}
 	o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
 	o.Published = p.Created
 	o.URL = p.CanonicalURL(cfg.App.Host)
 	o.AttributedTo = p.Collection.FederatedAccount()
 	o.CC = []string{
 		p.Collection.FederatedAccount() + "/followers",
 	}
 	o.Name = p.DisplayTitle()
 	p.augmentContent()
 	if p.HTMLContent == template.HTML("") {
 		p.formatContent(cfg, false)
 	}
 	o.Content = string(p.HTMLContent)
 	if p.Language.Valid {
 		o.ContentMap = map[string]string{
 			p.Language.String: string(p.HTMLContent),
 		}
 	}
 	if len(p.Tags) == 0 {
 		o.Tag = []activitystreams.Tag{}
 	} else {
 		var tagBaseURL string
 		if isSingleUser {
 			tagBaseURL = p.Collection.CanonicalURL() + "tag:"
 		} else {
 			if cfg.App.Chorus {
 				tagBaseURL = fmt.Sprintf("%s/read/t/", p.Collection.hostName)
 			} else {
 				tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
 			}
 		}
 		for _, t := range p.Tags {
 			o.Tag = append(o.Tag, activitystreams.Tag{
 				Type: activitystreams.TagHashtag,
 				HRef: tagBaseURL + t,
 				Name: "#" + t,
 			})
 		}
 	}
 	if len(p.Images) > 0 {
 		for _, i := range p.Images {
 			o.Attachment = append(o.Attachment, activitystreams.NewImageAttachment(i))
 		}
 	}
 	// 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
 }
 
 // TODO: merge this into getSlugFromPost or phase it out
 func getSlug(title, lang string) string {
 	return getSlugFromPost("", title, lang)
 }
 
 func getSlugFromPost(title, body, lang string) string {
 	if title == "" {
 		title = postTitle(body, body)
 	}
 	title = parse.PostLede(title, false)
 	// Truncate lede if needed
 	title, _ = parse.TruncToWord(title, 80)
 	var s string
 	if lang != "" && len(lang) == 2 {
 		s = slug.MakeLang(title, lang)
 	} else {
 		s = slug.Make(title)
 	}
 
 	// Transliteration may cause the slug to expand past the limit, so truncate again
 	s, _ = parse.TruncToWord(s, 80)
 	return strings.TrimFunc(s, func(r rune) bool {
 		// TruncToWord doesn't respect words in a slug, since spaces are replaced
 		// with hyphens. So remove any trailing hyphens.
 		return r == '-'
 	})
 }
 
 // isFontValid returns whether or not the submitted post's appearance is valid.
 func (p *SubmittedPost) isFontValid() bool {
 	validFonts := map[string]bool{
 		"norm": true,
 		"sans": true,
 		"mono": true,
 		"wrap": true,
 		"code": true,
 	}
 
 	_, valid := validFonts[p.Font]
 	return valid
 }
 
 func getRawPost(app *App, friendlyID string) *RawPost {
 	var content, font, title string
 	var isRTL sql.NullBool
 	var lang sql.NullString
 	var ownerID sql.NullInt64
 	var created, updated time.Time
 
 	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}
 	case err != nil:
 		return &RawPost{Content: "", Found: true, Gone: false}
 	}
 
 	return &RawPost{Title: title, Content: content, Font: font, Created: created, Updated: updated, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
 
 }
 
 // TODO; return a Post!
 func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
 	var id, title, content, font string
 	var isRTL sql.NullBool
 	var lang sql.NullString
 	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, 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, 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:
 		return &RawPost{Content: "", Found: false, Gone: false}
 	case err != nil:
 		return &RawPost{Content: "", Found: true, Gone: false}
 	}
 
 	return &RawPost{
 		Id:       id,
 		Slug:     slug,
 		Title:    title,
 		Content:  content,
 		Font:     font,
 		Created:  created,
 		Updated:  updated,
 		IsRTL:    isRTL,
 		Language: lang,
 		OwnerID:  ownerID.Int64,
 		Found:    true,
 		Gone:     content == "",
 		Views:    views,
 	}
 }
 
 func isRaw(r *http.Request) bool {
 	vars := mux.Vars(r)
 	slug := vars["slug"]
 
 	// NOTE: until this is done better, be sure to keep this in parity with
 	// isRaw in viewCollectionPost() and handleViewPost()
 	isJSON := strings.HasSuffix(slug, ".json")
 	isXML := strings.HasSuffix(slug, ".xml")
 	isMarkdown := strings.HasSuffix(slug, ".md")
 	return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
 }
 
 func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	slug := vars["slug"]
 
 	// NOTE: until this is done better, be sure to keep this in parity with
 	// isRaw() and handleViewPost()
 	isJSON := strings.HasSuffix(slug, ".json")
 	isXML := strings.HasSuffix(slug, ".xml")
 	isMarkdown := strings.HasSuffix(slug, ".md")
 	isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
 
 	cr := &collectionReq{}
 	err := processCollectionRequest(cr, vars, w, r)
 	if err != nil {
 		return err
 	}
 
 	// Check for hellbanned users
 	u, err := checkUserForCollection(app, cr, r, true)
 	if err != nil {
 		return err
 	}
 
 	// Normalize the URL, redirecting user to consistent post URL
 	if slug != strings.ToLower(slug) {
 		loc := fmt.Sprintf("/%s", strings.ToLower(slug))
 		if !app.cfg.App.SingleUser {
 			loc = "/" + cr.alias + loc
 		}
 		return impart.HTTPError{http.StatusMovedPermanently, loc}
 	}
 
 	// Display collection if this is a collection
 	var c *Collection
 	if app.cfg.App.SingleUser {
 		c, err = app.db.GetCollectionByID(1)
 	} else {
 		c, err = app.db.GetCollection(cr.alias)
 	}
 	if err != nil {
 		if err, ok := err.(impart.HTTPError); ok {
 			if err.Status == http.StatusNotFound {
 				// Redirect if necessary
 				newAlias := app.db.GetCollectionRedirect(cr.alias)
 				if newAlias != "" {
 					return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
 				}
 			}
 		}
 		return err
 	}
 	c.hostName = app.cfg.App.Host
 
 	silenced, err := app.db.IsUserSilenced(c.OwnerID)
 	if err != nil {
 		log.Error("view collection post: %v", err)
 	}
 
 	// Check collection permissions
 	if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
 		return ErrPostNotFound
 	}
 	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
 
 	if isRaw {
 		slug = strings.Split(slug, ".")[0]
 	}
 
 	// Fetch extra data about the Collection
 	// TODO: refactor out this logic, shared in collection.go:fetchCollection()
 	coll := NewCollectionObj(c)
 	owner, err := app.db.GetUserByID(coll.OwnerID)
 	if err != nil {
 		// Log the error and just continue
 		log.Error("Error getting user for collection: %v", err)
 	} else {
 		coll.Owner = owner
 	}
 
 	postFound := true
 	p, err := app.db.GetPost(slug, coll.ID)
 	if err != nil {
 		if err == ErrCollectionPageNotFound {
 			postFound = false
 
 			if slug == "feed" {
 				// User tried to access blog feed without a trailing slash, and
 				// there's no post with a slug "feed"
 				return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
 			}
 
 			po := &Post{
 				Slug:     null.NewString(slug, true),
 				Font:     "norm",
 				Language: zero.NewString("en", true),
 				RTL:      zero.NewBool(false, true),
 				Content: `This page is missing.
 
 Are you sure it was ever here?`,
 			}
 			pp := po.processPost()
 			p = &pp
 		} else {
 			return err
 		}
 	}
 	p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
 	p.Collection = coll
 	p.IsTopLevel = app.cfg.App.SingleUser
 
 	if !p.IsOwner && silenced {
 		return ErrPostNotFound
 	}
 	// Check if post has been unpublished
 	if p.Content == "" && p.Title.String == "" {
 		return impart.HTTPError{http.StatusGone, "Post was unpublished."}
 	}
 
 	p.augmentContent()
 
 	// Serve collection post
 	if isRaw {
 		contentType := "text/plain"
 		if isJSON {
 			contentType = "application/json"
 		} else if isXML {
 			contentType = "application/xml"
 		} else if isMarkdown {
 			contentType = "text/markdown"
 		}
 		w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
 		if !postFound {
 			w.WriteHeader(http.StatusNotFound)
 			fmt.Fprintf(w, "Post not found.")
 			// TODO: return error instead, so status is correctly reflected in logs
 			return nil
 		}
 		if isMarkdown && p.Title.String != "" {
 			fmt.Fprintf(w, "# %s\n\n", p.Title.String)
 		}
 		fmt.Fprint(w, p.Content)
 	} else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
 		if !postFound {
 			return ErrCollectionPageNotFound
 		}
 		p.extractData()
 		ap := p.ActivityObject(app)
 		ap.Context = []interface{}{activitystreams.Namespace}
 		setCacheControl(w, apCacheTime)
 		return impart.RenderActivityJSON(w, ap, http.StatusOK)
 	} else {
 		p.extractData()
 		p.Content = strings.Replace(p.Content, "", "", 1)
 		// TODO: move this to function
 		p.formatContent(app.cfg, cr.isCollOwner)
 		tp := struct {
 			*PublicPost
 			page.StaticPage
 			IsOwner        bool
 			IsPinned       bool
 			IsCustomDomain bool
 			Monetization   string
 			PinnedPosts    *[]PublicPost
 			IsFound        bool
 			IsAdmin        bool
 			CanInvite      bool
 			Silenced       bool
 		}{
 			PublicPost:     p,
 			StaticPage:     pageForReq(app, r),
 			IsOwner:        cr.isCollOwner,
 			IsCustomDomain: cr.isCustomDomain,
 			IsFound:        postFound,
 			Silenced:       silenced,
 		}
 		tp.IsAdmin = u != nil && u.IsAdmin()
 		tp.CanInvite = canUserInvite(app.cfg, tp.IsAdmin)
 		tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll, p.IsOwner)
 		tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
 		tp.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
 
 		if !postFound {
 			w.WriteHeader(http.StatusNotFound)
 		}
 		postTmpl := "collection-post"
 		if app.cfg.App.Chorus {
 			postTmpl = "chorus-collection-post"
 		}
 		if err := templates[postTmpl].ExecuteTemplate(w, "post", tp); err != nil {
 			log.Error("Error in collection-post template: %v", err)
 		}
 	}
 
 	go func() {
 		if p.OwnerID.Valid {
 			// Post is owned by someone. Don't update stats if owner is viewing the post.
 			if u != nil && p.OwnerID.Int64 == u.ID {
 				return
 			}
 		}
 		// Update stats for non-raw post views
 		if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
 			_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
 			if err != nil {
 				log.Error("Unable to update posts count: %v", err)
 			}
 		}
 	}()
 
 	return nil
 }
 
 // TODO: move this to utils after making it more generic
 func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
 	for _, e := range *sl {
 		if e.ID == s.ID {
 			return true
 		}
 	}
 	return false
 }
 
 func (p *Post) extractData() {
 	p.Tags = tags.Extract(p.Content)
 	p.extractImages()
 }
 
 func (rp *RawPost) UserFacingCreated() string {
 	return rp.Created.Format(postMetaDateFormat)
 }
 
 func (rp *RawPost) Created8601() string {
 	return rp.Created.Format("2006-01-02T15:04:05Z")
 }
 
 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() {
 	p.Images = extractImages(p.Content)
 }
 
 func extractImages(content string) []string {
 	matches := extract.ExtractUrls(content)
 	urls := map[string]bool{}
 	for i := range matches {
 		uRaw := matches[i].Text
 		// Parse the extracted text so we can examine the path
 		u, err := url.Parse(uRaw)
 		if err != nil {
 			continue
 		}
 		// 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)
 	}
 	return resURLs
 }
diff --git a/posts_test.go b/posts_test.go
index e423fd3..0c9bc95 100644
--- a/posts_test.go
+++ b/posts_test.go
@@ -1,35 +1,45 @@
+/*
+ * Copyright © 2020-2021 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_test
 
 import (
 	"testing"
 
 	"github.com/guregu/null/zero"
 	"github.com/stretchr/testify/assert"
-	"github.com/writeas/writefreely"
+	"github.com/writefreely/writefreely"
 )
 
 func TestPostSummary(t *testing.T) {
 	testCases := map[string]struct {
 		given    writefreely.Post
 		expected string
 	}{
 		"no special chars":          {givenPost("Content."), "Content."},
 		"HTML content":              {givenPost("Content with a
 paragraph."), "Content with a paragraph."},
 		"content with escaped char": {givenPost("Content's all OK."), "Content's all OK."},
 		"multiline content": {givenPost(`Content
 in
 multiple
 lines.`), "Content in multiple lines."},
 	}
 
 	for name, test := range testCases {
 		t.Run(name, func(t *testing.T) {
 			actual := test.given.Summary()
 			assert.Equal(t, test.expected, actual)
 		})
 	}
 }
 
 func givenPost(content string) writefreely.Post {
 	return writefreely.Post{Title: zero.StringFrom("Title"), Content: content}
 }
diff --git a/read.go b/read.go
index afe5651..f6808b4 100644
--- a/read.go
+++ b/read.go
@@ -1,334 +1,334 @@
 /*
- * Copyright © 2018-2020 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"database/sql"
 	"fmt"
 	"html/template"
 	"math"
 	"net/http"
 	"strconv"
 	"time"
 
 	. "github.com/gorilla/feeds"
 	"github.com/gorilla/mux"
 	stripmd "github.com/writeas/go-strip-markdown"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/web-core/memo"
-	"github.com/writeas/writefreely/page"
+	"github.com/writefreely/writefreely/page"
 )
 
 const (
 	tlFeedLimit      = 100
 	tlAPIPageLimit   = 10
 	tlMaxAuthorPosts = 5
 	tlPostsPerPage   = 16
 	tlMaxPostCache   = 250
 	tlCacheDur       = 10 * time.Minute
 )
 
 type localTimeline struct {
 	m     *memo.Memo
 	posts *[]PublicPost
 
 	// Configuration values
 	postsPerPage int
 }
 
 type readPublication struct {
 	page.StaticPage
 	Posts       *[]PublicPost
 	CurrentPage int
 	TotalPages  int
 	SelTopic    string
 	IsAdmin     bool
 	CanInvite   bool
 
 	// Customizable page content
 	ContentTitle string
 	Content      template.HTML
 }
 
 func initLocalTimeline(app *App) {
 	app.timeline = &localTimeline{
 		postsPerPage: tlPostsPerPage,
 		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.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()}
 	}
 	defer rows.Close()
 
 	ap := map[string]uint{}
 
 	posts := []PublicPost{}
 	for rows.Next() {
 		p := &Post{}
 		c := &Collection{}
 		var alias, title sql.NullString
 		err = rows.Scan(&p.ID, &alias, &title, &p.Slug, &p.Title, &p.Content, &p.Font, &p.Language, &p.RTL, &p.Created, &p.Updated)
 		if err != nil {
 			log.Error("[READ] Unable to scan row, skipping: %v", err)
 			continue
 		}
 		c.hostName = app.cfg.App.Host
 
 		isCollectionPost := alias.Valid
 		if isCollectionPost {
 			c.Alias = alias.String
 			if c.Alias != "" && ap[c.Alias] == tlMaxAuthorPosts {
 				// Don't add post if we've hit the post-per-author limit
 				continue
 			}
 
 			c.Public = true
 			c.Title = title.String
 		}
 
 		p.extractData()
 		p.HTMLContent = template.HTML(applyMarkdown([]byte(p.Content), "", app.cfg))
 		fp := p.processPost()
 		if isCollectionPost {
 			fp.Collection = &CollectionObj{Collection: *c}
 		}
 
 		posts = append(posts, fp)
 		ap[c.Alias]++
 	}
 
 	return posts, nil
 }
 
 func viewLocalTimelineAPI(app *App, w http.ResponseWriter, r *http.Request) error {
 	updateTimelineCache(app.timeline)
 
 	skip, _ := strconv.Atoi(r.FormValue("skip"))
 
 	posts := []PublicPost{}
 	for i := skip; i < skip+tlAPIPageLimit && i < len(*app.timeline.posts); i++ {
 		posts = append(posts, (*app.timeline.posts)[i])
 	}
 
 	return impart.WriteSuccess(w, posts, http.StatusOK)
 }
 
 func viewLocalTimeline(app *App, w http.ResponseWriter, r *http.Request) error {
 	if !app.cfg.App.LocalTimeline {
 		return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."}
 	}
 
 	vars := mux.Vars(r)
 	var p int
 	page := 1
 	p, _ = strconv.Atoi(vars["page"])
 	if p > 0 {
 		page = p
 	}
 
 	return showLocalTimeline(app, w, r, page, vars["author"], vars["tag"])
 }
 
 func updateTimelineCache(tl *localTimeline) {
 	// Fetch posts if enough time has passed since last cache
 	if tl.posts == nil || tl.m.Invalidate() {
 		log.Info("[READ] Updating post cache")
 		var err error
 		var postsInterfaces interface{}
 		postsInterfaces, err = tl.m.Get()
 		if err != nil {
 			log.Error("[READ] Unable to cache posts: %v", err)
 		} else {
 			castPosts := postsInterfaces.([]PublicPost)
 			tl.posts = &castPosts
 		}
 	}
 }
 
 func showLocalTimeline(app *App, w http.ResponseWriter, r *http.Request, page int, author, tag string) error {
 	updateTimelineCache(app.timeline)
 
 	pl := len(*(app.timeline.posts))
 	ttlPages := int(math.Ceil(float64(pl) / float64(app.timeline.postsPerPage)))
 
 	start := 0
 	if page > 1 {
 		start = app.timeline.postsPerPage * (page - 1)
 		if start > pl {
 			return impart.HTTPError{http.StatusFound, fmt.Sprintf("/read/p/%d", ttlPages)}
 		}
 	}
 	end := app.timeline.postsPerPage * page
 	if end > pl {
 		end = pl
 	}
 	var posts []PublicPost
 	if author != "" {
 		posts = []PublicPost{}
 		for _, p := range *app.timeline.posts {
 			if author == "anonymous" {
 				if p.Collection == nil {
 					posts = append(posts, p)
 				}
 			} else if p.Collection != nil && p.Collection.Alias == author {
 				posts = append(posts, p)
 			}
 		}
 	} else if tag != "" {
 		posts = []PublicPost{}
 		for _, p := range *app.timeline.posts {
 			if p.HasTag(tag) {
 				posts = append(posts, p)
 			}
 		}
 	} else {
 		posts = *app.timeline.posts
 		posts = posts[start:end]
 	}
 
 	d := &readPublication{
 		StaticPage:  pageForReq(app, r),
 		Posts:       &posts,
 		CurrentPage: page,
 		TotalPages:  ttlPages,
 		SelTopic:    tag,
 	}
 	if app.cfg.App.Chorus {
 		u := getUserSession(app, r)
 		d.IsAdmin = u != nil && u.IsAdmin()
 		d.CanInvite = canUserInvite(app.cfg, d.IsAdmin)
 	}
 	c, err := getReaderSection(app)
 	if err != nil {
 		return err
 	}
 	d.ContentTitle = c.Title.String
 	d.Content = template.HTML(applyMarkdown([]byte(c.Content), "", app.cfg))
 
 	err = templates["read"].ExecuteTemplate(w, "base", d)
 	if err != nil {
 		log.Error("Unable to render reader: %v", err)
 		fmt.Fprintf(w, ":(")
 	}
 	return nil
 }
 
 // NextPageURL provides a full URL for the next page of collection posts
 func (c *readPublication) NextPageURL(n int) string {
 	return fmt.Sprintf("/read/p/%d", n+1)
 }
 
 // PrevPageURL provides a full URL for the previous page of collection posts,
 // returning a /page/N result for pages >1
 func (c *readPublication) PrevPageURL(n int) string {
 	if n == 2 {
 		// Previous page is 1; no need for /p/ prefix
 		return "/read"
 	}
 	return fmt.Sprintf("/read/p/%d", n-1)
 }
 
 // handlePostIDRedirect handles a route where a post ID is given and redirects
 // the user to the canonical post URL.
 func handlePostIDRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	postID := vars["post"]
 	p, err := app.db.GetPost(postID, 0)
 	if err != nil {
 		return err
 	}
 
 	if !p.CollectionID.Valid {
 		// No collection; send to normal URL
 		// NOTE: not handling single user blogs here since this handler is only used for the Reader
 		return impart.HTTPError{http.StatusFound, app.cfg.App.Host + "/" + postID + ".md"}
 	}
 
 	c, err := app.db.GetCollectionBy("id = ?", fmt.Sprintf("%d", p.CollectionID.Int64))
 	if err != nil {
 		return err
 	}
 	c.hostName = app.cfg.App.Host
 
 	// Retrieve collection information and send user to canonical URL
 	return impart.HTTPError{http.StatusFound, c.CanonicalURL() + p.Slug.String}
 }
 
 func viewLocalTimelineFeed(app *App, w http.ResponseWriter, req *http.Request) error {
 	if !app.cfg.App.LocalTimeline {
 		return impart.HTTPError{http.StatusNotFound, "Page doesn't exist."}
 	}
 
 	updateTimelineCache(app.timeline)
 
 	feed := &Feed{
 		Title:       app.cfg.App.SiteName + " Reader",
 		Link:        &Link{Href: app.cfg.App.Host},
 		Description: "Read the latest posts from " + app.cfg.App.SiteName + ".",
 		Created:     time.Now(),
 	}
 
 	c := 0
 	var title, permalink, author string
 	for _, p := range *app.timeline.posts {
 		if c == tlFeedLimit {
 			break
 		}
 
 		title = p.PlainDisplayTitle()
 		permalink = p.CanonicalURL(app.cfg.App.Host)
 		if p.Collection != nil {
 			author = p.Collection.Title
 		} else {
 			author = "Anonymous"
 			permalink += ".md"
 		}
 		i := &Item{
 			Id:          app.cfg.App.Host + "/read/a/" + p.ID,
 			Title:       title,
 			Link:        &Link{Href: permalink},
 			Description: "",
 			Content:     applyMarkdown([]byte(p.Content), "", app.cfg),
 			Author:      &Author{author, ""},
 			Created:     p.Created,
 			Updated:     p.Updated,
 		}
 		feed.Items = append(feed.Items, i)
 		c++
 	}
 
 	rss, err := feed.ToRss()
 	if err != nil {
 		return err
 	}
 
 	fmt.Fprint(w, rss)
 	return nil
 }
diff --git a/static/js/README.md b/static/js/README.md
index 9d25cfc..7e387db 100644
--- a/static/js/README.md
+++ b/static/js/README.md
@@ -1,29 +1,29 @@
 # static/js
 
 This directory is for Javascript.
 
 ## Updating libraries
 
 Update instructions, for libraries that involve more than just downloading the latest version.
 
 ### highlightjs
 
 To update the highlightjs library, first download a plain package (no languages included) [from highlightjs.org](https://highlightjs.org/download/). The `highlight.pack.js` file in the archive should be moved into this `static/js/` directory and renamed to `highlight.min.js`.
 
 Then [download an archive](https://github.com/highlightjs/highlight.js/releases) of the latest version. Extract it to some directory, and replace **~/Downloads/highlight.js** below with the resulting directory.
 
 ```bash
 #!/bin/bash
 
 version=9.13.1
 
-cd $GOPATH/src/github.com/writeas/writefreely/static/js/highlightjs
+cd $GOPATH/src/github.com/writefreely/writefreely/static/js/highlightjs
 for f in $(ls ~/Downloads/highlight.js/src/languages); do
 	# Use minified versions
 	f=$(echo $f | sed 's/\.js/.min.js/')
 	# Download the version
 	wget "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/$version/languages/$f"
 done
 ```
 
 Commit the changes and you're done!
diff --git a/templates.go b/templates.go
index 846c5d8..3871258 100644
--- a/templates.go
+++ b/templates.go
@@ -1,229 +1,229 @@
 /*
- * Copyright © 2018 A Bunch Tell LLC.
+ * Copyright © 2018-2021 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 (
 	"errors"
 	"html/template"
 	"io"
 	"io/ioutil"
 	"net/http"
 	"os"
 	"path/filepath"
 	"strings"
 
 	"github.com/dustin/go-humanize"
 	"github.com/writeas/web-core/l10n"
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/config"
+	"github.com/writefreely/writefreely/config"
 )
 
 var (
 	templates = map[string]*template.Template{}
 	pages     = map[string]*template.Template{}
 	userPages = map[string]*template.Template{}
 	funcMap   = template.FuncMap{
 		"largeNumFmt": largeNumFmt,
 		"pluralize":   pluralize,
 		"isRTL":       isRTL,
 		"isLTR":       isLTR,
 		"localstr":    localStr,
 		"localhtml":   localHTML,
 		"tolower":     strings.ToLower,
 		"title":       strings.Title,
 		"hasPrefix":   strings.HasPrefix,
 		"hasSuffix":   strings.HasSuffix,
 		"dict":        dict,
 	}
 )
 
 const (
 	templatesDir = "templates"
 	pagesDir     = "pages"
 )
 
 func showUserPage(w http.ResponseWriter, name string, obj interface{}) {
 	if obj == nil {
 		log.Error("showUserPage: data is nil!")
 		return
 	}
 	if err := userPages[filepath.Join("user", name+".tmpl")].ExecuteTemplate(w, name, obj); err != nil {
 		log.Error("Error parsing %s: %v", name, err)
 	}
 }
 
 func initTemplate(parentDir, name string) {
 	if debugging {
 		log.Info("  " + filepath.Join(parentDir, templatesDir, name+".tmpl"))
 	}
 
 	files := []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", "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"
 		files = append(files, filepath.Join(parentDir, templatesDir, "include", "posts.tmpl"))
 	}
 	if name == "chorus-collection" || name == "chorus-collection-post" {
 		files = append(files, filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"))
 	}
 	if name == "collection" || name == "collection-tags" || name == "collection-post" || name == "post" || name == "chorus-collection" || name == "chorus-collection-post" {
 		files = append(files, filepath.Join(parentDir, templatesDir, "include", "post-render.tmpl"))
 	}
 	templates[name] = template.Must(template.New("").Funcs(funcMap).ParseFiles(files...))
 }
 
 func initPage(parentDir, path, key string) {
 	if debugging {
 		log.Info("  [%s] %s", key, path)
 	}
 
 	files := []string{
 		path,
 		filepath.Join(parentDir, templatesDir, "include", "footer.tmpl"),
 		filepath.Join(parentDir, templatesDir, "base.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) {
 	if debugging {
 		log.Info("  [%s] %s", key, path)
 	}
 
 	userPages[key] = template.Must(template.New(key).Funcs(funcMap).ParseFiles(
 		path,
 		filepath.Join(parentDir, templatesDir, "user", "include", "header.tmpl"),
 		filepath.Join(parentDir, templatesDir, "user", "include", "footer.tmpl"),
 		filepath.Join(parentDir, templatesDir, "user", "include", "silenced.tmpl"),
 		filepath.Join(parentDir, templatesDir, "user", "include", "nav.tmpl"),
 	))
 }
 
 // InitTemplates loads all template files from the configured parent dir.
 func InitTemplates(cfg *config.Config) error {
 	log.Info("Loading templates...")
 	tmplFiles, err := ioutil.ReadDir(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir))
 	if err != nil {
 		return err
 	}
 
 	for _, f := range tmplFiles {
 		if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
 			parts := strings.Split(f.Name(), ".")
 			key := parts[0]
 			initTemplate(cfg.Server.TemplatesParentDir, key)
 		}
 	}
 
 	log.Info("Loading pages...")
 	// Initialize all static pages that use the base template
 	filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, pagesDir), func(path string, i os.FileInfo, err error) error {
 		if !i.IsDir() && !strings.HasPrefix(i.Name(), ".") {
 			key := i.Name()
 			initPage(cfg.Server.PagesParentDir, path, key)
 		}
 
 		return nil
 	})
 
 	log.Info("Loading user pages...")
 	// Initialize all user pages that use base templates
 	filepath.Walk(filepath.Join(cfg.Server.TemplatesParentDir, templatesDir, "user"), func(path string, f os.FileInfo, err error) error {
 		if !f.IsDir() && !strings.HasPrefix(f.Name(), ".") {
 			corePath := path
 			if cfg.Server.TemplatesParentDir != "" {
 				corePath = corePath[len(cfg.Server.TemplatesParentDir)+1:]
 			}
 			parts := strings.Split(corePath, string(filepath.Separator))
 			key := f.Name()
 			if len(parts) > 2 {
 				key = filepath.Join(parts[1], f.Name())
 			}
 			initUserPage(cfg.Server.TemplatesParentDir, path, key)
 		}
 
 		return nil
 	})
 
 	return nil
 }
 
 // renderPage retrieves the given template and renders it to the given io.Writer.
 // If something goes wrong, the error is logged and returned.
 func renderPage(w io.Writer, tmpl string, data interface{}) error {
 	err := pages[tmpl].ExecuteTemplate(w, "base", data)
 	if err != nil {
 		log.Error("%v", err)
 	}
 	return err
 }
 
 func largeNumFmt(n int64) string {
 	return humanize.Comma(n)
 }
 
 func pluralize(singular, plural string, n int64) string {
 	if n == 1 {
 		return singular
 	}
 	return plural
 }
 
 func isRTL(d string) bool {
 	return d == "rtl"
 }
 
 func isLTR(d string) bool {
 	return d == "ltr" || d == "auto"
 }
 
 func localStr(term, lang string) string {
 	s := l10n.Strings(lang)[term]
 	if s == "" {
 		s = l10n.Strings("")[term]
 	}
 	return s
 }
 
 func localHTML(term, lang string) template.HTML {
 	s := l10n.Strings(lang)[term]
 	if s == "" {
 		s = l10n.Strings("")[term]
 	}
 	s = strings.Replace(s, "write.as", "writefreely", 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
 }
diff --git a/templates/include/footer.tmpl b/templates/include/footer.tmpl
index 0f258e7..c6f4b87 100644
--- a/templates/include/footer.tmpl
+++ b/templates/include/footer.tmpl
@@ -1,44 +1,44 @@
 {{define "footer"}}
 		
 {{end}}
diff --git a/users.go b/users.go
index 9b5c99c..add76cd 100644
--- a/users.go
+++ b/users.go
@@ -1,132 +1,132 @@
 /*
- * Copyright © 2018 A Bunch Tell LLC.
+ * Copyright © 2018-2019, 2021 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 (
 	"time"
 
 	"github.com/guregu/null/zero"
 	"github.com/writeas/web-core/data"
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/key"
+	"github.com/writefreely/writefreely/key"
 )
 
 type UserStatus int
 
 const (
 	UserActive = iota
 	UserSilenced
 )
 
 type (
 	userCredentials struct {
 		Alias string `json:"alias" schema:"alias"`
 		Pass  string `json:"pass" schema:"pass"`
 		Email string `json:"email" schema:"email"`
 		Web   bool   `json:"web" schema:"-"`
 		To    string `json:"-" schema:"to"`
 
 		EmailLogin bool `json:"via_email" schema:"via_email"`
 	}
 
 	userRegistration struct {
 		userCredentials
 		InviteCode string `json:"invite_code" schema:"invite_code"`
 		Honeypot   string `json:"fullname" schema:"fullname"`
 		Normalize  bool   `json:"normalize" schema:"normalize"`
 		Signup     bool   `json:"signup" schema:"signup"`
 	}
 
 	// AuthUser contains information for a newly authenticated user (either
 	// from signing up or logging in).
 	AuthUser struct {
 		AccessToken string `json:"access_token,omitempty"`
 		Password    string `json:"password,omitempty"`
 		User        *User  `json:"user"`
 
 		// Verbose user data
 		Posts       *[]PublicPost `json:"posts,omitempty"`
 		Collections *[]Collection `json:"collections,omitempty"`
 	}
 
 	// User is a consistent user object in the database and all contexts (auth
 	// and non-auth) in the API.
 	User struct {
 		ID         int64       `json:"-"`
 		Username   string      `json:"username"`
 		HashedPass []byte      `json:"-"`
 		HasPass    bool        `json:"has_pass"`
 		Email      zero.String `json:"email"`
 		Created    time.Time   `json:"created"`
 		Status     UserStatus  `json:"status"`
 
 		clearEmail string `json:"email"`
 	}
 
 	userMeStats struct {
 		TotalCollections, TotalArticles, CollectionPosts uint64
 	}
 
 	ExportUser struct {
 		*User
 		Collections    *[]CollectionObj `json:"collections"`
 		AnonymousPosts []PublicPost     `json:"posts"`
 	}
 
 	PublicUser struct {
 		Username string `json:"username"`
 	}
 )
 
 // EmailClear decrypts and returns the user's email, caching it in the user
 // object.
 func (u *User) EmailClear(keys *key.Keychain) string {
 	if u.clearEmail != "" {
 		return u.clearEmail
 	}
 
 	if u.Email.Valid && u.Email.String != "" {
 		email, err := data.Decrypt(keys.EmailKey, []byte(u.Email.String))
 		if err != nil {
 			log.Error("Error decrypting user email: %v", err)
 		} else {
 			u.clearEmail = string(email)
 			return u.clearEmail
 		}
 	}
 	return ""
 }
 
 func (u User) CreatedFriendly() string {
 	/*
 		// TODO: accept a locale in this method and use that for the format
 		var loc monday.Locale = monday.LocaleEnUS
 		return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
 	*/
 	return u.Created.Format("January 2, 2006, 3:04 PM")
 }
 
 // Cookie strips down an AuthUser to contain only information necessary for
 // cookies.
 func (u User) Cookie() *User {
 	u.HashedPass = []byte{}
 
 	return &u
 }
 
 func (u *User) IsAdmin() bool {
 	// TODO: get this from database
 	return u.ID == 1
 }
 
 func (u *User) IsSilenced() bool {
 	return u.Status&UserSilenced != 0
 }
diff --git a/webfinger.go b/webfinger.go
index 581d940..6c1341f 100644
--- a/webfinger.go
+++ b/webfinger.go
@@ -1,145 +1,145 @@
 /*
  * Copyright © 2018-2021 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 (
 	"encoding/json"
 	"io/ioutil"
 	"net/http"
 	"strings"
 
 	"github.com/writeas/go-webfinger"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/log"
-	"github.com/writeas/writefreely/config"
+	"github.com/writefreely/writefreely/config"
 )
 
 type wfResolver struct {
 	db  *datastore
 	cfg *config.Config
 }
 
 var wfUserNotFoundErr = impart.HTTPError{http.StatusNotFound, "User not found."}
 
 func (wfr wfResolver) FindUser(username string, host, requestHost string, r []webfinger.Rel) (*webfinger.Resource, error) {
 	var c *Collection
 	var err error
 	if username == host {
 		c = instanceColl
 	} else if wfr.cfg.App.SingleUser {
 		c, err = wfr.db.GetCollectionByID(1)
 	} else {
 		c, err = wfr.db.GetCollection(username)
 	}
 	if err != nil {
 		log.Error("Unable to get blog: %v", err)
 		return nil, err
 	}
 	c.hostName = wfr.cfg.App.Host
 
 	if !c.IsInstanceColl() {
 		silenced, err := wfr.db.IsUserSilenced(c.OwnerID)
 		if err != nil {
 			log.Error("webfinger find user: check is silenced: %v", err)
 			return nil, err
 		}
 		if silenced {
 			return nil, wfUserNotFoundErr
 		}
 	}
 	if wfr.cfg.App.SingleUser {
 		// Ensure handle matches user-chosen one on single-user blogs
 		if username != c.Alias {
 			log.Info("Username '%s' is not handle '%s'", username, c.Alias)
 			return nil, wfUserNotFoundErr
 		}
 	}
 	// Only return information if site has federation enabled.
 	// TODO: enable two levels of federation? Unlisted or Public on timelines?
 	if !wfr.cfg.App.Federation {
 		return nil, wfUserNotFoundErr
 	}
 
 	res := webfinger.Resource{
 		Subject: "acct:" + username + "@" + host,
 		Aliases: []string{
 			c.CanonicalURL(),
 			c.FederatedAccount(),
 		},
 		Links: []webfinger.Link{
 			{
 				HRef: c.CanonicalURL(),
 				Type: "text/html",
 				Rel:  "https://webfinger.net/rel/profile-page",
 			},
 			{
 				HRef: c.FederatedAccount(),
 				Type: "application/activity+json",
 				Rel:  "self",
 			},
 		},
 	}
 	return &res, nil
 }
 
 func (wfr wfResolver) DummyUser(username string, hostname string, r []webfinger.Rel) (*webfinger.Resource, error) {
 	return nil, wfUserNotFoundErr
 }
 
 func (wfr wfResolver) IsNotFoundError(err error) bool {
 	return err == wfUserNotFoundErr
 }
 
 // RemoteLookup looks up a user by handle at a remote server
 // and returns the actor URL
 func RemoteLookup(handle string) string {
 	handle = strings.TrimLeft(handle, "@")
 	// let's take the server part of the handle
 	parts := strings.Split(handle, "@")
 	resp, err := http.Get("https://" + parts[1] + "/.well-known/webfinger?resource=acct:" + handle)
 	if err != nil {
 		log.Error("Error on webfinger request: %v", err)
 		return ""
 	}
 
 	body, err := ioutil.ReadAll(resp.Body)
 	if err != nil {
 		log.Error("Error on webfinger response: %v", err)
 		return ""
 	}
 
 	var result webfinger.Resource
 	err = json.Unmarshal(body, &result)
 	if err != nil {
 		log.Error("Unable to parse webfinger response: %v", err)
 		return ""
 	}
 
 	var href string
 	// iterate over webfinger links and find the one with
 	// a self "rel"
 	for _, link := range result.Links {
 		if link.Rel == "self" {
 			href = link.HRef
 		}
 	}
 
 	// if we didn't find it with the above then
 	// try using aliases
 	if href == "" {
 		// take the last alias because mastodon has the
 		// https://instance.tld/@user first which
 		// doesn't work as an href
 		href = result.Aliases[len(result.Aliases)-1]
 	}
 
 	return href
 }