diff --git a/account.go b/account.go
index 25f1e0d..5fb87f0 100644
--- a/account.go
+++ b/account.go
@@ -1,1099 +1,1099 @@
 /*
  * Copyright © 2018-2019 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"
 )
 
 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
 	}
 )
 
 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) {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 
 	// 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.Header.Get("Content-Type"))
 
 	// 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
 	// TODO: remove this var
 	createdWithPass := true
 	hashedPass, err := auth.HashPass([]byte(signup.Pass))
 	if err != nil {
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
 	}
 
 	// Create struct to insert
 	u := &User{
 		Username:   signup.Alias,
 		HashedPass: hashedPass,
 		HasPass:    createdWithPass,
 		Email:      zero.NewString("", signup.Email != ""),
 		Created:    time.Now().Truncate(time.Second).UTC(),
 	}
 	if signup.Email != "" {
 		encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email)
 		if err != nil {
 			log.Error("Unable to encrypt email: %s\n", err)
 		} else {
 			u.Email.String = string(encEmail)
 		}
 	}
 
 	// Create actual user
 	if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
 		return nil, err
 	}
 
 	// Log invite if needed
 	if signup.InviteCode != "" {
 		cu, err := app.db.GetUserForAuth(signup.Alias)
 		if err != nil {
 			return nil, err
 		}
 		err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
 		if err != nil {
 			return nil, err
 		}
 	}
 
 	// Add back unencrypted data for response
 	if signup.Email != "" {
 		u.Email.String = signup.Email
 	}
 
 	resUser := &AuthUser{
 		User: u,
 	}
 	if !createdWithPass {
 		resUser.Password = signup.Pass
 	}
 	title := signup.Alias
 	if signup.Normalize {
 		title = desiredUsername
 	}
 	resUser.Collections = &[]Collection{
 		{
 			Alias: signup.Alias,
 			Title: title,
 		},
 	}
 
 	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
 		To            string
 		Message       template.HTML
 		Flashes       []template.HTML
 		LoginUsername string
 	}{
 		pageForReq(app, r),
 		r.FormValue("to"),
 		template.HTML(""),
 		[]template.HTML{},
 		getTempInfo(app, "login-user", r, w),
 	}
 
 	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.Header.Get("Content-Type"))
 	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
 
 	// 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 !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.Header.Get("Content-Type"))
 	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(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.Header.Get("Content-Type"))
 	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.Header.Get("Content-Type"))
 	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.Header.Get("Content-Type"))
 	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)
 	}
 
 	suspended, err := app.db.IsUserSuspended(u.ID)
 	if err != nil {
 		log.Error("view articles: %v", err)
 	}
 	d := struct {
 		*UserPage
 		AnonymousPosts *[]PublicPost
 		Collections    *[]Collection
 		Suspended      bool
 	}{
 		UserPage:       NewUserPage(app, r, u, u.Username+"'s Posts", f),
 		AnonymousPosts: p,
 		Collections:    c,
 		Suspended:      suspended,
 	}
 	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
 
 	suspended, err := app.db.IsUserSuspended(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
 		Suspended        bool
 	}{
 		UserPage:         NewUserPage(app, r, u, u.Username+"'s Blogs", f),
 		Collections:      c,
 		UsedCollections:  int(uc),
 		NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
 		Suspended:        suspended,
 	}
 	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
 	}
 
 	suspended, err := app.db.IsUserSuspended(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
 		Suspended bool
 	}{
 		UserPage:   NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
 		Collection: c,
 		Suspended:  suspended,
 	}
 
 	showUserPage(w, "collection", obj)
 	return nil
 }
 
 func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 
 	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() + " "
 	}
 
 	suspended, err := app.db.IsUserSuspended(u.ID)
 	if err != nil {
 		log.Error("view stats: %v", err)
 		return err
 	}
 	obj := struct {
 		*UserPage
 		VisitsBlog  string
 		Collection  *Collection
 		TopPosts    *[]PublicPost
 		APFollowers int
 		Suspended   bool
 	}{
 		UserPage:   NewUserPage(app, r, u, titleStats+"Stats", flashes),
 		VisitsBlog: alias,
 		Collection: c,
 		TopPosts:   topPosts,
 		Suspended:  suspended,
 	}
 	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)
 
 	obj := struct {
 		*UserPage
 		Email     string
 		HasPass   bool
 		IsLogOut  bool
 		Suspended bool
 	}{
 		UserPage:  NewUserPage(app, r, u, "Account Settings", flashes),
 		Email:     fullUser.EmailClear(app.keys),
 		HasPass:   passIsSet,
 		IsLogOut:  r.FormValue("logout") == "1",
-		Suspended: fullUser.IsSuspended(),
+		Suspended: fullUser.IsSilenced(),
 	}
 
 	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
 }
diff --git a/admin.go b/admin.go
index 2e5b8a5..e624bfb 100644
--- a/admin.go
+++ b/admin.go
@@ -1,483 +1,483 @@
 /*
  * Copyright © 2018-2019 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"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/auth"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/writefreely/appstats"
 	"github.com/writeas/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
 }
 
 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 {
 	updateAppStats()
 	p := struct {
 		*UserPage
 		SysStatus systemStatus
 		Config    config.AppCfg
 
 		Message, ConfigMessage string
 	}{
 		UserPage:  NewUserPage(app, r, u, "Admin", nil),
 		SysStatus: sysStatus,
 		Config:    app.cfg.App,
 
 		Message:       r.FormValue("m"),
 		ConfigMessage: r.FormValue("cm"),
 	}
 
 	showUserPage(w, "admin", p)
 	return nil
 }
 
 func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p := struct {
 		*UserPage
 		Config  config.AppCfg
 		Message string
 
 		Users      *[]User
 		CurPage    int
 		TotalUsers int64
 		TotalPages []int
 	}{
 		UserPage: NewUserPage(app, r, u, "Users", nil),
 		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
 		Config  config.AppCfg
 		Message string
 
 		User     *User
 		Colls    []inspectedCollection
 		LastPost string
 
 		TotalPosts int64
 	}{
 		Config:  app.cfg.App,
 		Message: r.FormValue("m"),
 		Colls:   []inspectedCollection{},
 	}
 
 	var err error
 	p.User, err = app.db.GetUserForAuth(username)
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user: %v", err)}
 	}
 	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.IsSuspended() {
+	if user.IsSilenced() {
 		err = app.db.SetUserStatus(user.ID, UserActive)
 	} else {
-		err = app.db.SetUserStatus(user.ID, UserSuspended)
+		err = app.db.SetUserStatus(user.ID, UserSilenced)
 	}
 	if err != nil {
 		log.Error("toggle user suspended: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v")}
 	}
 	return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
 }
 
 func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p := struct {
 		*UserPage
 		Config  config.AppCfg
 		Message string
 
 		Pages []*instanceContent
 	}{
 		UserPage: NewUserPage(app, r, u, "Pages", nil),
 		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
 		Config  config.AppCfg
 		Message string
 
 		Banner  *instanceContent
 		Content *instanceContent
 	}{
 		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.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" + 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
 }
diff --git a/collections.go b/collections.go
index fe9d89f..3e86f30 100644
--- a/collections.go
+++ b/collections.go
@@ -1,1122 +1,1122 @@
 /*
  * Copyright © 2018 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"
 )
 
 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"`
 		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"`
 
 		db       *datastore
 		hostName string
 	}
 	CollectionObj struct {
 		Collection
 		TotalPosts int           `json:"total_posts"`
 		Owner      *User         `json:"owner,omitempty"`
 		Posts      *[]PublicPost `json:"posts,omitempty"`
 	}
 	DisplayCollection struct {
 		*CollectionObj
 		Prefix      string
 		IsTopLevel  bool
 		CurrentPage int
 		TotalPages  int
 		Format      *CollectionFormat
 		Suspended   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"`
 		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) 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")
 	}
 	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.Header.Get("Content-Type"))
 	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
 	}
 	suspended, err := app.db.IsUserSuspended(userID)
 	if err != nil {
 		log.Error("new collection: %v", err)
 		return ErrInternalGeneral
 	}
 	if suspended {
 		return ErrUserSuspended
 	}
 
 	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.Header.Get("Content-Type"))
 	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 suspended
 	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
 	Collections    *[]Collection
 	PinnedPosts    *[]PublicPost
 	IsAdmin        bool
 	CanInvite      bool
 }
 
 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
 			}
 
 			// 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: &CollectionObj{Collection: *c},
 		CurrentPage:   page,
 		Prefix:        cr.prefix,
 		IsTopLevel:    isSingleUser,
 		Format:        c.NewFormat(),
 	}
 	c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
 	return coll
 }
 
 func getCollectionPage(vars map[string]string) int {
 	page := 1
 	var p int
 	p, _ = strconv.Atoi(vars["page"])
 	if p > 0 {
 		page = p
 	}
 	return page
 }
 
 // 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
 
 	suspended, err := app.db.IsUserSuspended(c.OwnerID)
 	if err != nil {
 		log.Error("view collection: %v", err)
 		return ErrInternalGeneral
 	}
 
 	// Serve ActivityStreams data now, if requested
 	if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
 		ac := c.PersonObject()
 		ac.Context = []interface{}{activitystreams.Namespace}
 		return impart.RenderActivityJSON(w, ac, http.StatusOK)
 	}
 
 	// Fetch extra data about the Collection
 	// TODO: refactor out this logic, shared in collection.go:fetchCollection()
 	coll := 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 && suspended {
 		return ErrCollectionNotFound
 	}
 	displayPage.Suspended = isOwner && suspended
 	displayPage.Owner = owner
 	coll.Owner = displayPage.Owner
 
 	// Add more data
 	// TODO: fix this mess of collections inside collections
 	displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
 
 	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 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 !isOwner && u.IsSuspended() {
+	if !isOwner && u.IsSilenced() {
 		return ErrCollectionNotFound
 	}
-	displayPage.Suspended = u.IsSuspended()
+	displayPage.Suspended = u.IsSilenced()
 	displayPage.Owner = owner
 	coll.Owner = displayPage.Owner
 	// Add more data
 	// TODO: fix this mess of collections inside collections
 	displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
 
 	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.Header.Get("Content-Type"))
 	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
 		}
 	}
 
 	suspended, err := app.db.IsUserSuspended(u.ID)
 	if err != nil {
 		log.Error("existing collection: %v", err)
 		return ErrInternalGeneral
 	}
 
 	if suspended {
 		return ErrUserSuspended
 	}
 
 	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 4b4f4dc..d78d888 100644
--- a/database.go
+++ b/database.go
@@ -1,2485 +1,2485 @@
 /*
  * Copyright © 2018 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"
 	"strings"
 	"time"
 
 	"github.com/guregu/null"
 	"github.com/guregu/null/zero"
 	uuid "github.com/nu7hatch/gouuid"
 	"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"
 )
 
 const (
 	mySQLErrDuplicateKey = 1062
 
 	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) (l *string, err 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)
 
 	DatabaseInitialized() bool
 }
 
 type datastore struct {
 	*sql.DB
 	driverName string
 }
 
 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)
 }
 
 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
 }
 
 // IsUserSuspended returns true if the user account associated with id is
 // currently suspended.
 func (db *datastore) IsUserSuspended(id int64) (bool, error) {
 	u := &User{ID: id}
 
 	err := db.QueryRow("SELECT status FROM users WHERE id = ?", id).Scan(&u.Status)
 	switch {
 	case err == sql.ErrNoRows:
 		return false, fmt.Errorf("is user suspended: %v", ErrUserNotFound)
 	case err != nil:
 		log.Error("Couldn't SELECT user password: %v", err)
 		return false, fmt.Errorf("is user suspended: %v", err)
 	}
 
-	return u.IsSuspended(), nil
+	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.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, format zero.String
 	row := db.QueryRow("SELECT id, alias, title, description, style_sheet, script, format, owner_id, privacy, view_count FROM collections WHERE "+condition, value)
 
 	err := row.Scan(&c.ID, &c.Alias, &c.Title, &c.Description, &styleSheet, &script, &format, &c.OwnerID, &c.Visibility, &c.Views)
 	switch {
 	case err == sql.ErrNoRows:
 		return nil, impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
 	case err != nil:
 		log.Error("Failed selecting from collections: %v", err)
 		return nil, err
 	}
 	c.StyleSheet = styleSheet.String
 	c.Script = script.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")
 
 	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 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.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.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()
 
 		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) 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 {
 		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) DeleteAccount(userID int64) (l *string, err error) {
 	debug := ""
 	l = &debug
 
 	t, err := db.Begin()
 	if err != nil {
 		stringLogln(l, "Unable to begin: %v", err)
 		return
 	}
 
 	// Get all collections
 	rows, err := db.Query("SELECT id, alias FROM collections WHERE owner_id = ?", userID)
 	if err != nil {
 		t.Rollback()
 		stringLogln(l, "Unable to get collections: %v", err)
 		return
 	}
 	defer rows.Close()
 	colls := []Collection{}
 	var c Collection
 	for rows.Next() {
 		err = rows.Scan(&c.ID, &c.Alias)
 		if err != nil {
 			t.Rollback()
 			stringLogln(l, "Unable to scan collection cols: %v", err)
 			return
 		}
 		colls = append(colls, c)
 	}
 
 	var res sql.Result
 	for _, c := range colls {
 		// TODO: user deleteCollection() func
 		// Delete tokens
 		res, err = t.Exec("DELETE FROM collectionattributes WHERE collection_id = ?", c.ID)
 		if err != nil {
 			t.Rollback()
 			stringLogln(l, "Unable to delete attributes on %s: %v", c.Alias, err)
 			return
 		}
 		rs, _ := res.RowsAffected()
 		stringLogln(l, "Deleted %d for %s from collectionattributes", rs, c.Alias)
 
 		// Remove any optional collection password
 		res, err = t.Exec("DELETE FROM collectionpasswords WHERE collection_id = ?", c.ID)
 		if err != nil {
 			t.Rollback()
 			stringLogln(l, "Unable to delete passwords on %s: %v", c.Alias, err)
 			return
 		}
 		rs, _ = res.RowsAffected()
 		stringLogln(l, "Deleted %d for %s from collectionpasswords", rs, c.Alias)
 
 		// Remove redirects to this collection
 		res, err = t.Exec("DELETE FROM collectionredirects WHERE new_alias = ?", c.Alias)
 		if err != nil {
 			t.Rollback()
 			stringLogln(l, "Unable to delete redirects on %s: %v", c.Alias, err)
 			return
 		}
 		rs, _ = res.RowsAffected()
 		stringLogln(l, "Deleted %d for %s from collectionredirects", rs, c.Alias)
 	}
 
 	// Delete collections
 	res, err = t.Exec("DELETE FROM collections WHERE owner_id = ?", userID)
 	if err != nil {
 		t.Rollback()
 		stringLogln(l, "Unable to delete collections: %v", err)
 		return
 	}
 	rs, _ := res.RowsAffected()
 	stringLogln(l, "Deleted %d from collections", rs)
 
 	// Delete tokens
 	res, err = t.Exec("DELETE FROM accesstokens WHERE user_id = ?", userID)
 	if err != nil {
 		t.Rollback()
 		stringLogln(l, "Unable to delete access tokens: %v", err)
 		return
 	}
 	rs, _ = res.RowsAffected()
 	stringLogln(l, "Deleted %d from accesstokens", rs)
 
 	// Delete posts
 	res, err = t.Exec("DELETE FROM posts WHERE owner_id = ?", userID)
 	if err != nil {
 		t.Rollback()
 		stringLogln(l, "Unable to delete posts: %v", err)
 		return
 	}
 	rs, _ = res.RowsAffected()
 	stringLogln(l, "Deleted %d from posts", rs)
 
 	res, err = t.Exec("DELETE FROM userattributes WHERE user_id = ?", userID)
 	if err != nil {
 		t.Rollback()
 		stringLogln(l, "Unable to delete attributes: %v", err)
 		return
 	}
 	rs, _ = res.RowsAffected()
 	stringLogln(l, "Deleted %d from userattributes", rs)
 
 	res, err = t.Exec("DELETE FROM users WHERE id = ?", userID)
 	if err != nil {
 		t.Rollback()
 		stringLogln(l, "Unable to delete user: %v", err)
 		return
 	}
 	rs, _ = res.RowsAffected()
 	stringLogln(l, "Deleted %d from users", rs)
 
 	err = t.Commit()
 	if err != nil {
 		t.Rollback()
 		stringLogln(l, "Unable to commit: %v", err)
 		return
 	}
 
 	return
 }
 
 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:
 		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
 }
 
 // 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 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
 }
diff --git a/invites.go b/invites.go
index 5f04c69..1dba7bd 100644
--- a/invites.go
+++ b/invites.go
@@ -1,179 +1,179 @@
 /*
  * Copyright © 2019 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"
 )
 
 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) 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
 	}{
 		UserPage: NewUserPage(app, r, u, "Invite People", f),
 	}
 
 	var err error
 	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.IsSuspended() {
+	if u.IsSilenced() {
 		return ErrUserSuspended
 	}
 
 	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
 		Error   string
 		Flashes []template.HTML
 		Invite  string
 	}{
 		StaticPage: pageForReq(app, r),
 		Invite:     inviteCode,
 	}
 
 	if expired {
 		p.Error = "This invite link has expired."
 	}
 
 	// 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/templates/user/admin/view-user.tmpl b/templates/user/admin/view-user.tmpl
index dc7b2ef..be50b12 100644
--- a/templates/user/admin/view-user.tmpl
+++ b/templates/user/admin/view-user.tmpl
@@ -1,126 +1,126 @@
 {{define "view-user"}}
 {{template "header" .}}
 <style>
 table.classy th {
 	text-align: left;
 }
 h3 {
 	font-weight: normal;
 }
 td.active-suspend {
 	display: flex;
 	align-items: center;
 }
 
 td.active-suspend > input[type="submit"] {
 	margin-left: auto;
 	margin-right: 5%;
 }
 
 @media only screen and (max-width: 500px) {
 	td.active-suspend {
 		flex-wrap: wrap;
 	}
 	td.active-suspend > input[type="submit"] {
 		margin: auto;
 	}
 }
 </style>
 <div class="snug content-container">
 	{{template "admin-header" .}}
 
 	<h2 id="posts-header">{{.User.Username}}</h2>
 
 	<table class="classy export">
 		<tr>
 			<th>No.</th>
 			<td>{{.User.ID}}</td>
 		</tr>
 		<tr>
 			<th>Type</th>
 			<td>{{if .User.IsAdmin}}Admin{{else}}User{{end}}</td>
 		</tr>
 		<tr>
 			<th>Username</th>
 			<td>{{.User.Username}}</td>
 		</tr>
 		<tr>
 			<th>Joined</th>
 			<td>{{.User.CreatedFriendly}}</td>
 		</tr>
 		<tr>
 			<th>Total Posts</th>
 			<td>{{.TotalPosts}}</td>
 		</tr>
 		<tr>
 			<th>Last Post</th>
 			<td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
 		</tr>
 		<tr>
 			<form action="/admin/user/{{.User.Username}}/status" method="POST" {{if not .User.IsSilenced}}onsubmit="return confirmSilence()"{{end}}>
 				<a id="status"/>
 				<th>Status</th>
 				<td class="active-suspend">
-				{{if .User.IsSuspended}}
+				{{if .User.IsSilenced}}
 					<p>Silenced</p>
 					<input type="submit" value="Unsilence"/>
 				{{else}}
 					<p>Active</p>
 					<input class="danger" type="submit" value="Silence" {{if .User.IsAdmin}}disabled{{end}}/>
 				{{end}}
 				</td>
 			</form>
 		</tr>
 	</table>
 
 	<h2>Blogs</h2>
 
 	{{range .Colls}}
 	<h3><a href="/{{.Alias}}/">{{.Title}}</a></h3>
 	<table class="classy export">
 		<tr>
 			<th>Alias</th>
 			<td>{{.Alias}}</td>
 		</tr>
 		<tr>
 			<th>Title</th>
 			<td>{{.Title}}</td>
 		</tr>
 		<tr>
 			<th>Description</th>
 			<td>{{.Description}}</td>
 		</tr>
 		<tr>
 			<th>Visibility</th>
 			<td>{{.FriendlyVisibility}}</td>
 		</tr>
 		<tr>
 			<th>Views</th>
 			<td>{{.Views}}</td>
 		</tr>
 		<tr>
 			<th>Posts</th>
 			<td>{{.TotalPosts}}</td>
 		</tr>
 		<tr>
 			<th>Last Post</th>
 			<td>{{if .LastPost}}{{.LastPost}}{{else}}Never{{end}}</td>
 		</tr>
 		{{if $.Config.Federation}}
 		<tr>
 			<th>Fediverse Followers</th>
 			<td>{{.Followers}}</td>
 		</tr>
 		{{end}}
 	</table>
 	{{end}}
 </div>
 
 <script type="text/javascript">
 function confirmSilence() {
 	return confirm("Silence this user? They'll still be able to log in and access their posts, but no one else will be able to see them anymore. You can reverse this decision at any time.");
 }
 </script>
 
 {{template "footer" .}}
 {{end}}
diff --git a/users.go b/users.go
index 5eb2e61..9b5c99c 100644
--- a/users.go
+++ b/users.go
@@ -1,132 +1,132 @@
 /*
  * Copyright © 2018 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"
 )
 
 type UserStatus int
 
 const (
 	UserActive = iota
-	UserSuspended
+	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) IsSuspended() bool {
-	return u.Status&UserSuspended != 0
+func (u *User) IsSilenced() bool {
+	return u.Status&UserSilenced != 0
 }