diff --git a/app.go b/app.go
index b25e168..5ff6ebd 100644
--- a/app.go
+++ b/app.go
@@ -1,759 +1,760 @@
 /*
  * 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"
 	"html/template"
 	"io/ioutil"
 	"net/http"
 	"net/url"
 	"os"
 	"os/signal"
 	"path/filepath"
 	"regexp"
 	"strings"
 	"syscall"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/gorilla/schema"
 	"github.com/gorilla/sessions"
 	"github.com/manifoldco/promptui"
 	"github.com/writeas/go-strip-markdown"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/auth"
 	"github.com/writeas/web-core/converter"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/writefreely/author"
 	"github.com/writeas/writefreely/config"
 	"github.com/writeas/writefreely/key"
 	"github.com/writeas/writefreely/migrations"
 	"github.com/writeas/writefreely/page"
 )
 
 const (
 	staticDir       = "static"
 	assumedTitleLen = 80
 	postsPerPage    = 10
 
 	serverSoftware = "WriteFreely"
 	softwareURL    = "https://writefreely.org"
 )
 
 var (
 	debugging bool
 
 	// Software version can be set from git env using -ldflags
 	softwareVer = "0.10.0"
 
 	// DEPRECATED VARS
 	isSingleUser bool
 )
 
 // App holds data and configuration for an individual WriteFreely instance.
 type App struct {
 	router       *mux.Router
 	shttp        *http.ServeMux
 	db           *datastore
 	cfg          *config.Config
 	cfgFile      string
 	keys         *key.Keychain
 	sessionStore *sessions.CookieStore
 	formDecoder  *schema.Decoder
 
 	timeline *localTimeline
 }
 
 // DB returns the App's datastore
 func (app *App) DB() *datastore {
 	return app.db
 }
 
 // Router returns the App's router
 func (app *App) Router() *mux.Router {
 	return app.router
 }
 
 // Config returns the App's current configuration.
 func (app *App) Config() *config.Config {
 	return app.cfg
 }
 
 // SetConfig updates the App's Config to the given value.
 func (app *App) SetConfig(cfg *config.Config) {
 	app.cfg = cfg
 }
 
 // SetKeys updates the App's Keychain to the given value.
 func (app *App) SetKeys(k *key.Keychain) {
 	app.keys = k
 }
 
 // Apper is the interface for getting data into and out of a WriteFreely
 // instance (or "App").
 //
 // App returns the App for the current instance.
 //
 // LoadConfig reads an app configuration into the App, returning any error
 // encountered.
 //
 // SaveConfig persists the current App configuration.
 //
 // LoadKeys reads the App's encryption keys and loads them into its
 // key.Keychain.
 type Apper interface {
 	App() *App
 
 	LoadConfig() error
 	SaveConfig(*config.Config) error
 
 	LoadKeys() error
 }
 
 // App returns the App
 func (app *App) App() *App {
 	return app
 }
 
 // LoadConfig loads and parses a config file.
 func (app *App) LoadConfig() error {
 	log.Info("Loading %s configuration...", app.cfgFile)
 	cfg, err := config.Load(app.cfgFile)
 	if err != nil {
 		log.Error("Unable to load configuration: %v", err)
 		os.Exit(1)
 		return err
 	}
 	app.cfg = cfg
 	return nil
 }
 
 // SaveConfig saves the given Config to disk -- namely, to the App's cfgFile.
 func (app *App) SaveConfig(c *config.Config) error {
 	return config.Save(c, app.cfgFile)
 }
 
 // LoadKeys reads all needed keys from disk into the App. In order to use the
 // configured `Server.KeysParentDir`, you must call initKeyPaths(App) before
 // this.
 func (app *App) LoadKeys() error {
 	var err error
 	app.keys = &key.Keychain{}
 
 	if debugging {
 		log.Info("  %s", emailKeyPath)
 	}
 	app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
 	if err != nil {
 		return err
 	}
 
 	if debugging {
 		log.Info("  %s", cookieAuthKeyPath)
 	}
 	app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
 	if err != nil {
 		return err
 	}
 
 	if debugging {
 		log.Info("  %s", cookieKeyPath)
 	}
 	app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
 	if err != nil {
 		return err
 	}
 
 	return nil
 }
 
 // handleViewHome shows page at root path. Will be the Pad if logged in and the
 // catch-all landing page otherwise.
 func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
 	if app.cfg.App.SingleUser {
 		// Render blog index
 		return handleViewCollection(app, w, r)
 	}
 
 	// Multi-user instance
 	forceLanding := r.FormValue("landing") == "1"
 	if !forceLanding {
 		// Show correct page based on user auth status and configured landing path
 		u := getUserSession(app, r)
 		if u != nil {
 			// User is logged in, so show the Pad
 			return handleViewPad(app, w, r)
 		}
 
 		if land := app.cfg.App.LandingPath(); land != "/" {
 			return impart.HTTPError{http.StatusFound, land}
 		}
 	}
 
 	p := struct {
 		page.StaticPage
 		Flashes []template.HTML
 		Banner  template.HTML
 		Content template.HTML
 
 		ForcedLanding bool
 	}{
 		StaticPage:    pageForReq(app, r),
 		ForcedLanding: forceLanding,
 	}
 
 	banner, err := getLandingBanner(app)
 	if err != nil {
 		log.Error("unable to get landing banner: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
 	}
 	p.Banner = template.HTML(applyMarkdown([]byte(banner.Content), ""))
 
 	content, err := getLandingBody(app)
 	if err != nil {
 		log.Error("unable to get landing content: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get content: %v", err)}
 	}
 	p.Content = template.HTML(applyMarkdown([]byte(content.Content), ""))
 
 	// Get error messages
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		// Ignore this
 		log.Error("Unable to get session in handleViewHome; ignoring: %v", err)
 	}
 	flashes, _ := getSessionFlashes(app, w, r, session)
 	for _, flash := range flashes {
 		p.Flashes = append(p.Flashes, template.HTML(flash))
 	}
 
 	// Show landing page
 	return renderPage(w, "landing.tmpl", p)
 }
 
 func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *template.Template) error {
 	p := struct {
 		page.StaticPage
 		ContentTitle string
 		Content      template.HTML
 		PlainContent string
 		Updated      string
 
 		AboutStats *InstanceStats
 	}{
 		StaticPage: pageForReq(app, r),
 	}
 	if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
 		var c *instanceContent
 		var err error
 
 		if r.URL.Path == "/about" {
 			c, err = getAboutPage(app)
 
 			// Fetch stats
 			p.AboutStats = &InstanceStats{}
 			p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
 			p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
 		} else {
 			c, err = getPrivacyPage(app)
 		}
 
 		if err != nil {
 			return err
 		}
 		p.ContentTitle = c.Title.String
 		p.Content = template.HTML(applyMarkdown([]byte(c.Content), ""))
 		p.PlainContent = shortPostDescription(stripmd.Strip(c.Content))
 		if !c.Updated.IsZero() {
 			p.Updated = c.Updated.Format("January 2, 2006")
 		}
 	}
 
 	// Serve templated page
 	err := t.ExecuteTemplate(w, "base", p)
 	if err != nil {
 		log.Error("Unable to render page: %v", err)
 	}
 	return nil
 }
 
 func pageForReq(app *App, r *http.Request) page.StaticPage {
 	p := page.StaticPage{
 		AppCfg:  app.cfg.App,
 		Path:    r.URL.Path,
 		Version: "v" + softwareVer,
 	}
 
 	// Add user information, if given
 	var u *User
 	accessToken := r.FormValue("t")
 	if accessToken != "" {
 		userID := app.db.GetUserID(accessToken)
 		if userID != -1 {
 			var err error
 			u, err = app.db.GetUserByID(userID)
 			if err == nil {
 				p.Username = u.Username
 			}
 		}
 	} else {
 		u = getUserSession(app, r)
 		if u != nil {
 			p.Username = u.Username
 		}
 	}
+	p.CanViewReader = !app.cfg.App.Private || u != nil
 
 	return p
 }
 
 var fileRegex = regexp.MustCompile("/([^/]*\\.[^/]*)$")
 
 // Initialize loads the app configuration and initializes templates, keys,
 // session, route handlers, and the database connection.
 func Initialize(apper Apper, debug bool) (*App, error) {
 	debugging = debug
 
 	apper.LoadConfig()
 
 	// Load templates
 	err := InitTemplates(apper.App().Config())
 	if err != nil {
 		return nil, fmt.Errorf("load templates: %s", err)
 	}
 
 	// Load keys and set up session
 	initKeyPaths(apper.App()) // TODO: find a better way to do this, since it's unneeded in all Apper implementations
 	err = InitKeys(apper)
 	if err != nil {
 		return nil, fmt.Errorf("init keys: %s", err)
 	}
 	apper.App().InitSession()
 
 	apper.App().InitDecoder()
 
 	err = ConnectToDatabase(apper.App())
 	if err != nil {
 		return nil, fmt.Errorf("connect to DB: %s", err)
 	}
 
 	// Handle local timeline, if enabled
 	if apper.App().cfg.App.LocalTimeline {
 		log.Info("Initializing local timeline...")
 		initLocalTimeline(apper.App())
 	}
 
 	return apper.App(), nil
 }
 
 func Serve(app *App, r *mux.Router) {
 	log.Info("Going to serve...")
 
 	isSingleUser = app.cfg.App.SingleUser
 	app.cfg.Server.Dev = debugging
 
 	// Handle shutdown
 	c := make(chan os.Signal, 2)
 	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
 	go func() {
 		<-c
 		log.Info("Shutting down...")
 		shutdown(app)
 		log.Info("Done.")
 		os.Exit(0)
 	}()
 
 	// Start web application server
 	var bindAddress = app.cfg.Server.Bind
 	if bindAddress == "" {
 		bindAddress = "localhost"
 	}
 	var err error
 	if app.cfg.IsSecureStandalone() {
 		log.Info("Serving redirects on http://%s:80", bindAddress)
 		go func() {
 			err = http.ListenAndServe(
 				fmt.Sprintf("%s:80", bindAddress), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 					http.Redirect(w, r, app.cfg.App.Host, http.StatusMovedPermanently)
 				}))
 			log.Error("Unable to start redirect server: %v", err)
 		}()
 
 		log.Info("Serving on https://%s:443", bindAddress)
 		log.Info("---")
 		err = http.ListenAndServeTLS(
 			fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
 	} else {
 		log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
 		log.Info("---")
 		err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
 	}
 	if err != nil {
 		log.Error("Unable to start: %v", err)
 		os.Exit(1)
 	}
 }
 
 func (app *App) InitDecoder() {
 	// TODO: do this at the package level, instead of the App level
 	// Initialize modules
 	app.formDecoder = schema.NewDecoder()
 	app.formDecoder.RegisterConverter(converter.NullJSONString{}, converter.ConvertJSONNullString)
 	app.formDecoder.RegisterConverter(converter.NullJSONBool{}, converter.ConvertJSONNullBool)
 	app.formDecoder.RegisterConverter(sql.NullString{}, converter.ConvertSQLNullString)
 	app.formDecoder.RegisterConverter(sql.NullBool{}, converter.ConvertSQLNullBool)
 	app.formDecoder.RegisterConverter(sql.NullInt64{}, converter.ConvertSQLNullInt64)
 	app.formDecoder.RegisterConverter(sql.NullFloat64{}, converter.ConvertSQLNullFloat64)
 }
 
 // ConnectToDatabase validates and connects to the configured database, then
 // tests the connection.
 func ConnectToDatabase(app *App) error {
 	// Check database configuration
 	if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
 		return fmt.Errorf("Database user or password not set.")
 	}
 	if app.cfg.Database.Host == "" {
 		app.cfg.Database.Host = "localhost"
 	}
 	if app.cfg.Database.Database == "" {
 		app.cfg.Database.Database = "writefreely"
 	}
 
 	// TODO: check err
 	connectToDatabase(app)
 
 	// Test database connection
 	err := app.db.Ping()
 	if err != nil {
 		return fmt.Errorf("Database ping failed: %s", err)
 	}
 
 	return nil
 }
 
 // OutputVersion prints out the version of the application.
 func OutputVersion() {
 	fmt.Println(serverSoftware + " " + softwareVer)
 }
 
 // NewApp creates a new app instance.
 func NewApp(cfgFile string) *App {
 	return &App{
 		cfgFile: cfgFile,
 	}
 }
 
 // CreateConfig creates a default configuration and saves it to the app's cfgFile.
 func CreateConfig(app *App) error {
 	log.Info("Creating configuration...")
 	c := config.New()
 	log.Info("Saving configuration %s...", app.cfgFile)
 	err := config.Save(c, app.cfgFile)
 	if err != nil {
 		return fmt.Errorf("Unable to save configuration: %v", err)
 	}
 	return nil
 }
 
 // DoConfig runs the interactive configuration process.
 func DoConfig(app *App, configSections string) {
 	if configSections == "" {
 		configSections = "server db app"
 	}
 	// let's check there aren't any garbage in the list
 	configSectionsArray := strings.Split(configSections, " ")
 	for _, element := range configSectionsArray {
 		if element != "server" && element != "db" && element != "app" {
 			log.Error("Invalid argument to --sections. Valid arguments are only \"server\", \"db\" and \"app\"")
 			os.Exit(1)
 		}
 	}
 	d, err := config.Configure(app.cfgFile, configSections)
 	if err != nil {
 		log.Error("Unable to configure: %v", err)
 		os.Exit(1)
 	}
 	app.cfg = d.Config
 	connectToDatabase(app)
 	defer shutdown(app)
 
 	if !app.db.DatabaseInitialized() {
 		err = adminInitDatabase(app)
 		if err != nil {
 			log.Error(err.Error())
 			os.Exit(1)
 		}
 	} else {
 		log.Info("Database already initialized.")
 	}
 
 	if d.User != nil {
 		u := &User{
 			Username:   d.User.Username,
 			HashedPass: d.User.HashedPass,
 			Created:    time.Now().Truncate(time.Second).UTC(),
 		}
 
 		// Create blog
 		log.Info("Creating user %s...\n", u.Username)
 		err = app.db.CreateUser(u, app.cfg.App.SiteName)
 		if err != nil {
 			log.Error("Unable to create user: %s", err)
 			os.Exit(1)
 		}
 		log.Info("Done!")
 	}
 	os.Exit(0)
 }
 
 // GenerateKeyFiles creates app encryption keys and saves them into the configured KeysParentDir.
 func GenerateKeyFiles(app *App) error {
 	// Read keys path from config
 	app.LoadConfig()
 
 	// Create keys dir if it doesn't exist yet
 	fullKeysDir := filepath.Join(app.cfg.Server.KeysParentDir, keysDir)
 	if _, err := os.Stat(fullKeysDir); os.IsNotExist(err) {
 		err = os.Mkdir(fullKeysDir, 0700)
 		if err != nil {
 			return err
 		}
 	}
 
 	// Generate keys
 	initKeyPaths(app)
 	// TODO: use something like https://github.com/hashicorp/go-multierror to return errors
 	var keyErrs error
 	err := generateKey(emailKeyPath)
 	if err != nil {
 		keyErrs = err
 	}
 	err = generateKey(cookieAuthKeyPath)
 	if err != nil {
 		keyErrs = err
 	}
 	err = generateKey(cookieKeyPath)
 	if err != nil {
 		keyErrs = err
 	}
 
 	return keyErrs
 }
 
 // CreateSchema creates all database tables needed for the application.
 func CreateSchema(apper Apper) error {
 	apper.LoadConfig()
 	connectToDatabase(apper.App())
 	defer shutdown(apper.App())
 	err := adminInitDatabase(apper.App())
 	if err != nil {
 		return err
 	}
 	return nil
 }
 
 // Migrate runs all necessary database migrations.
 func Migrate(app *App) error {
 	app.LoadConfig()
 	connectToDatabase(app)
 	defer shutdown(app)
 
 	err := migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName))
 	if err != nil {
 		return fmt.Errorf("migrate: %s", err)
 	}
 	return nil
 }
 
 // ResetPassword runs the interactive password reset process.
 func ResetPassword(app *App, username string) error {
 	// Connect to the database
 	app.LoadConfig()
 	connectToDatabase(app)
 	defer shutdown(app)
 
 	// Fetch user
 	u, err := app.db.GetUserForAuth(username)
 	if err != nil {
 		log.Error("Get user: %s", err)
 		os.Exit(1)
 	}
 
 	// Prompt for new password
 	prompt := promptui.Prompt{
 		Templates: &promptui.PromptTemplates{
 			Success: "{{ . | bold | faint }}: ",
 		},
 		Label: "New password",
 		Mask:  '*',
 	}
 	newPass, err := prompt.Run()
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 
 	// Do the update
 	log.Info("Updating...")
 	err = adminResetPassword(app, u, newPass)
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 	log.Info("Success.")
 	return nil
 }
 
 func connectToDatabase(app *App) {
 	log.Info("Connecting to %s database...", app.cfg.Database.Type)
 
 	var db *sql.DB
 	var err error
 	if app.cfg.Database.Type == driverMySQL {
 		db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String())))
 		db.SetMaxOpenConns(50)
 	} else if app.cfg.Database.Type == driverSQLite {
 		if !SQLiteEnabled {
 			log.Error("Invalid database type '%s'. Binary wasn't compiled with SQLite3 support.", app.cfg.Database.Type)
 			os.Exit(1)
 		}
 		if app.cfg.Database.FileName == "" {
 			log.Error("SQLite database filename value in config.ini is empty.")
 			os.Exit(1)
 		}
 		db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared")
 		db.SetMaxOpenConns(1)
 	} else {
 		log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type)
 		os.Exit(1)
 	}
 	if err != nil {
 		log.Error("%s", err)
 		os.Exit(1)
 	}
 	app.db = &datastore{db, app.cfg.Database.Type}
 }
 
 func shutdown(app *App) {
 	log.Info("Closing database connection...")
 	app.db.Close()
 }
 
 // CreateUser creates a new admin or normal user from the given credentials.
 func CreateUser(apper Apper, username, password string, isAdmin bool) error {
 	// Create an admin user with --create-admin
 	apper.LoadConfig()
 	connectToDatabase(apper.App())
 	defer shutdown(apper.App())
 
 	// Ensure an admin / first user doesn't already exist
 	firstUser, _ := apper.App().db.GetUserByID(1)
 	if isAdmin {
 		// Abort if trying to create admin user, but one already exists
 		if firstUser != nil {
 			return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
 		}
 	} else {
 		// Abort if trying to create regular user, but no admin exists yet
 		if firstUser == nil {
 			return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
 		}
 	}
 
 	// Create the user
 	// Normalize and validate username
 	desiredUsername := username
 	username = getSlug(username, "")
 
 	usernameDesc := username
 	if username != desiredUsername {
 		usernameDesc += " (originally: " + desiredUsername + ")"
 	}
 
 	if !author.IsValidUsername(apper.App().cfg, username) {
 		return fmt.Errorf("Username %s is invalid, reserved, or shorter than configured minimum length (%d characters).", usernameDesc, apper.App().cfg.App.MinUsernameLen)
 	}
 
 	// Hash the password
 	hashedPass, err := auth.HashPass([]byte(password))
 	if err != nil {
 		return fmt.Errorf("Unable to hash password: %v", err)
 	}
 
 	u := &User{
 		Username:   username,
 		HashedPass: hashedPass,
 		Created:    time.Now().Truncate(time.Second).UTC(),
 	}
 
 	userType := "user"
 	if isAdmin {
 		userType = "admin"
 	}
 	log.Info("Creating %s %s...", userType, usernameDesc)
 	err = apper.App().db.CreateUser(u, desiredUsername)
 	if err != nil {
 		return fmt.Errorf("Unable to create user: %s", err)
 	}
 	log.Info("Done!")
 	return nil
 }
 
 func adminInitDatabase(app *App) error {
 	schemaFileName := "schema.sql"
 	if app.cfg.Database.Type == driverSQLite {
 		schemaFileName = "sqlite.sql"
 	}
 
 	schema, err := Asset(schemaFileName)
 	if err != nil {
 		return fmt.Errorf("Unable to load schema file: %v", err)
 	}
 
 	tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
 
 	queries := strings.Split(string(schema), ";\n")
 	for _, q := range queries {
 		if strings.TrimSpace(q) == "" {
 			continue
 		}
 		parts := tblReg.FindStringSubmatch(q)
 		if len(parts) >= 3 {
 			log.Info("Creating table %s...", parts[2])
 		} else {
 			log.Info("Creating table ??? (Weird query) No match in: %v", parts)
 		}
 		_, err = app.db.Exec(q)
 		if err != nil {
 			log.Error("%s", err)
 		} else {
 			log.Info("Created.")
 		}
 	}
 
 	// Set up migrations table
 	log.Info("Initializing appmigrations table...")
 	err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
 	if err != nil {
 		return fmt.Errorf("Unable to set initial migrations: %v", err)
 	}
 
 	log.Info("Running migrations...")
 	err = migrations.Migrate(migrations.NewDatastore(app.db.DB, app.db.driverName))
 	if err != nil {
 		return fmt.Errorf("migrate: %s", err)
 	}
 
 	log.Info("Done.")
 	return nil
 }
diff --git a/config/config.go b/config/config.go
index add5447..8009208 100644
--- a/config/config.go
+++ b/config/config.go
@@ -1,177 +1,179 @@
 /*
  * 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 config holds and assists in the configuration of a writefreely instance.
 package config
 
 import (
 	"gopkg.in/ini.v1"
 	"strings"
 )
 
 const (
 	// FileName is the default configuration file name
 	FileName = "config.ini"
 
 	UserNormal UserType = "user"
 	UserAdmin           = "admin"
 )
 
 type (
 	UserType string
 
 	// ServerCfg holds values that affect how the HTTP server runs
 	ServerCfg struct {
 		HiddenHost string `ini:"hidden_host"`
 		Port       int    `ini:"port"`
 		Bind       string `ini:"bind"`
 
 		TLSCertPath string `ini:"tls_cert_path"`
 		TLSKeyPath  string `ini:"tls_key_path"`
 
 		TemplatesParentDir string `ini:"templates_parent_dir"`
 		StaticParentDir    string `ini:"static_parent_dir"`
 		PagesParentDir     string `ini:"pages_parent_dir"`
 		KeysParentDir      string `ini:"keys_parent_dir"`
 
 		Dev bool `ini:"-"`
 	}
 
 	// DatabaseCfg holds values that determine how the application connects to a datastore
 	DatabaseCfg struct {
 		Type     string `ini:"type"`
 		FileName string `ini:"filename"`
 		User     string `ini:"username"`
 		Password string `ini:"password"`
 		Database string `ini:"database"`
 		Host     string `ini:"host"`
 		Port     int    `ini:"port"`
 	}
 
 	// AppCfg holds values that affect how the application functions
 	AppCfg struct {
 		SiteName string `ini:"site_name"`
 		SiteDesc string `ini:"site_description"`
 		Host     string `ini:"host"`
 
 		// Site appearance
 		Theme      string `ini:"theme"`
 		JSDisabled bool   `ini:"disable_js"`
 		WebFonts   bool   `ini:"webfonts"`
 		Landing    string `ini:"landing"`
 
 		// Users
 		SingleUser       bool `ini:"single_user"`
 		OpenRegistration bool `ini:"open_registration"`
 		MinUsernameLen   int  `ini:"min_username_len"`
 		MaxBlogs         int  `ini:"max_blogs"`
 
 		// Federation
 		Federation  bool `ini:"federation"`
 		PublicStats bool `ini:"public_stats"`
-		Private     bool `ini:"private"`
+
+		// Access
+		Private bool `ini:"private"`
 
 		// Additional functions
 		LocalTimeline bool   `ini:"local_timeline"`
 		UserInvites   string `ini:"user_invites"`
 	}
 
 	// Config holds the complete configuration for running a writefreely instance
 	Config struct {
 		Server   ServerCfg   `ini:"server"`
 		Database DatabaseCfg `ini:"database"`
 		App      AppCfg      `ini:"app"`
 	}
 )
 
 // New creates a new Config with sane defaults
 func New() *Config {
 	c := &Config{
 		Server: ServerCfg{
 			Port: 8080,
 			Bind: "localhost", /* IPV6 support when not using localhost? */
 		},
 		App: AppCfg{
 			Host:           "http://localhost:8080",
 			Theme:          "write",
 			WebFonts:       true,
 			SingleUser:     true,
 			MinUsernameLen: 3,
 			MaxBlogs:       1,
 			Federation:     true,
 			PublicStats:    true,
 		},
 	}
 	c.UseMySQL(true)
 	return c
 }
 
 // UseMySQL resets the Config's Database to use default values for a MySQL setup.
 func (cfg *Config) UseMySQL(fresh bool) {
 	cfg.Database.Type = "mysql"
 	if fresh {
 		cfg.Database.Host = "localhost"
 		cfg.Database.Port = 3306
 	}
 }
 
 // UseSQLite resets the Config's Database to use default values for a SQLite setup.
 func (cfg *Config) UseSQLite(fresh bool) {
 	cfg.Database.Type = "sqlite3"
 	if fresh {
 		cfg.Database.FileName = "writefreely.db"
 	}
 }
 
 // IsSecureStandalone returns whether or not the application is running as a
 // standalone server with TLS enabled.
 func (cfg *Config) IsSecureStandalone() bool {
 	return cfg.Server.Port == 443 && cfg.Server.TLSCertPath != "" && cfg.Server.TLSKeyPath != ""
 }
 
 func (ac *AppCfg) LandingPath() string {
 	if !strings.HasPrefix(ac.Landing, "/") {
 		return "/" + ac.Landing
 	}
 	return ac.Landing
 }
 
 // Load reads the given configuration file, then parses and returns it as a Config.
 func Load(fname string) (*Config, error) {
 	if fname == "" {
 		fname = FileName
 	}
 	cfg, err := ini.Load(fname)
 	if err != nil {
 		return nil, err
 	}
 
 	// Parse INI file
 	uc := &Config{}
 	err = cfg.MapTo(uc)
 	if err != nil {
 		return nil, err
 	}
 	return uc, nil
 }
 
 // Save writes the given Config to the given file.
 func Save(uc *Config, fname string) error {
 	cfg := ini.Empty()
 	err := ini.ReflectFrom(cfg, uc)
 	if err != nil {
 		return err
 	}
 
 	if fname == "" {
 		fname = FileName
 	}
 	return cfg.SaveTo(fname)
 }
diff --git a/handle.go b/handle.go
index 81a4823..4f137b9 100644
--- a/handle.go
+++ b/handle.go
@@ -1,687 +1,844 @@
 /*
  * 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 (
 	"fmt"
 	"html/template"
 	"net/http"
 	"net/url"
 	"runtime/debug"
 	"strconv"
 	"strings"
 	"time"
 
 	"github.com/gorilla/sessions"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/log"
+	"github.com/writeas/writefreely/config"
 	"github.com/writeas/writefreely/page"
 )
 
+// UserLevel represents the required user level for accessing an endpoint
 type UserLevel int
 
 const (
-	UserLevelNone         UserLevel = iota // user or not -- ignored
-	UserLevelOptional                      // user or not -- object fetched if user
-	UserLevelNoneRequired                  // non-user (required)
-	UserLevelUser                          // user (required)
+	UserLevelNoneType         UserLevel = iota // user or not -- ignored
+	UserLevelOptionalType                      // user or not -- object fetched if user
+	UserLevelNoneRequiredType                  // non-user (required)
+	UserLevelUserType                          // user (required)
 )
 
+func UserLevelNone(cfg *config.Config) UserLevel {
+	return UserLevelNoneType
+}
+
+func UserLevelOptional(cfg *config.Config) UserLevel {
+	return UserLevelOptionalType
+}
+
+func UserLevelNoneRequired(cfg *config.Config) UserLevel {
+	return UserLevelNoneRequiredType
+}
+
+func UserLevelUser(cfg *config.Config) UserLevel {
+	return UserLevelUserType
+}
+
+// UserLevelReader returns the permission level required for any route where
+// users can read published content.
+func UserLevelReader(cfg *config.Config) UserLevel {
+	if cfg.App.Private {
+		return UserLevelUserType
+	}
+	return UserLevelOptionalType
+}
+
 type (
 	handlerFunc          func(app *App, w http.ResponseWriter, r *http.Request) error
 	userHandlerFunc      func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
 	userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error
 	dataHandlerFunc      func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
 	authFunc             func(app *App, r *http.Request) (*User, error)
+	UserLevelFunc        func(cfg *config.Config) UserLevel
 )
 
 type Handler struct {
 	errors       *ErrorPages
 	sessionStore *sessions.CookieStore
 	app          Apper
 }
 
 // ErrorPages hold template HTML error pages for displaying errors to the user.
 // In each, there should be a defined template named "base".
 type ErrorPages struct {
 	NotFound            *template.Template
 	Gone                *template.Template
 	InternalServerError *template.Template
 	Blank               *template.Template
 }
 
 // NewHandler returns a new Handler instance, using the given StaticPage data,
 // and saving alias to the application's CookieStore.
 func NewHandler(apper Apper) *Handler {
 	h := &Handler{
 		errors: &ErrorPages{
 			NotFound:            template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")),
 			Gone:                template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
 			InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
 			Blank:               template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
 		},
 		sessionStore: apper.App().sessionStore,
 		app:          apper,
 	}
 
 	return h
 }
 
 // NewWFHandler returns a new Handler instance, using WriteFreely template files.
 // You MUST call writefreely.InitTemplates() before this.
 func NewWFHandler(apper Apper) *Handler {
 	h := NewHandler(apper)
 	h.SetErrorPages(&ErrorPages{
 		NotFound:            pages["404-general.tmpl"],
 		Gone:                pages["410.tmpl"],
 		InternalServerError: pages["500.tmpl"],
 		Blank:               pages["blank.tmpl"],
 	})
 	return h
 }
 
 // SetErrorPages sets the given set of ErrorPages as templates for any errors
 // that come up.
 func (h *Handler) SetErrorPages(e *ErrorPages) {
 	h.errors = e
 }
 
 // User handles requests made in the web application by the authenticated user.
 // This provides user-friendly HTML pages and actions that work in the browser.
 func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = http.StatusInternalServerError
 				}
 
 				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 			}()
 
 			u := getUserSession(h.app.App(), r)
 			if u == nil {
 				err := ErrNotLoggedIn
 				status = err.Status
 				return err
 			}
 
 			err := f(h.app.App(), u, w, r)
 			if err == nil {
 				status = http.StatusOK
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = http.StatusInternalServerError
 			}
 
 			return err
 		}())
 	}
 }
 
 // Admin handles requests on /admin routes
 func (h *Handler) Admin(f userHandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = http.StatusInternalServerError
 				}
 
 				log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent()))
 			}()
 
 			u := getUserSession(h.app.App(), r)
 			if u == nil || !u.IsAdmin() {
 				err := impart.HTTPError{http.StatusNotFound, ""}
 				status = err.Status
 				return err
 			}
 
 			err := f(h.app.App(), u, w, r)
 			if err == nil {
 				status = http.StatusOK
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = http.StatusInternalServerError
 			}
 
 			return err
 		}())
 	}
 }
 
 // AdminApper handles requests on /admin routes that require an Apper.
 func (h *Handler) AdminApper(f userApperHandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = http.StatusInternalServerError
 				}
 
 				log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent()))
 			}()
 
 			u := getUserSession(h.app.App(), r)
 			if u == nil || !u.IsAdmin() {
 				err := impart.HTTPError{http.StatusNotFound, ""}
 				status = err.Status
 				return err
 			}
 
 			err := f(h.app, u, w, r)
 			if err == nil {
 				status = http.StatusOK
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = http.StatusInternalServerError
 			}
 
 			return err
 		}())
 	}
 }
 
+func apiAuth(app *App, r *http.Request) (*User, error) {
+	// Authorize user from Authorization header
+	t := r.Header.Get("Authorization")
+	if t == "" {
+		return nil, ErrNoAccessToken
+	}
+	u := &User{ID: app.db.GetUserID(t)}
+	if u.ID == -1 {
+		return nil, ErrBadAccessToken
+	}
+
+	return u, nil
+}
+
+// optionaAPIAuth is used for endpoints that accept authenticated requests via
+// Authorization header or cookie, unlike apiAuth. It returns a different err
+// in the case where no Authorization header is present.
+func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
+	// Authorize user from Authorization header
+	t := r.Header.Get("Authorization")
+	if t == "" {
+		return nil, ErrNotLoggedIn
+	}
+	u := &User{ID: app.db.GetUserID(t)}
+	if u.ID == -1 {
+		return nil, ErrBadAccessToken
+	}
+
+	return u, nil
+}
+
+func webAuth(app *App, r *http.Request) (*User, error) {
+	u := getUserSession(app, r)
+	if u == nil {
+		return nil, ErrNotLoggedIn
+	}
+	return u, nil
+}
+
 // UserAPI handles requests made in the API by the authenticated user.
 // This provides user-friendly HTML pages and actions that work in the browser.
 func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
-	return h.UserAll(false, f, func(app *App, r *http.Request) (*User, error) {
-		// Authorize user from Authorization header
-		t := r.Header.Get("Authorization")
-		if t == "" {
-			return nil, ErrNoAccessToken
-		}
-		u := &User{ID: app.db.GetUserID(t)}
-		if u.ID == -1 {
-			return nil, ErrBadAccessToken
-		}
-
-		return u, nil
-	})
+	return h.UserAll(false, f, apiAuth)
 }
 
 func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		handleFunc := func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
 					status = 500
 				}
 
 				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 			}()
 
 			u, err := a(h.app.App(), r)
 			if err != nil {
 				if err, ok := err.(impart.HTTPError); ok {
 					status = err.Status
 				} else {
 					status = 500
 				}
 				return err
 			}
 
 			err = f(h.app.App(), u, w, r)
 			if err == nil {
 				status = 200
 			} else if err, ok := err.(impart.HTTPError); ok {
 				status = err.Status
 			} else {
 				status = 500
 			}
 
 			return err
 		}
 
 		if web {
 			h.handleHTTPError(w, r, handleFunc())
 		} else {
 			h.handleError(w, r, handleFunc())
 		}
 	}
 }
 
 func (h *Handler) RedirectOnErr(f handlerFunc, loc string) handlerFunc {
 	return func(app *App, w http.ResponseWriter, r *http.Request) error {
 		err := f(app, w, r)
 		if err != nil {
 			if ie, ok := err.(impart.HTTPError); ok {
 				// Override default redirect with returned error's, if it's a
 				// redirect error.
 				if ie.Status == http.StatusFound {
 					return ie
 				}
 			}
 			return impart.HTTPError{http.StatusFound, loc}
 		}
 		return nil
 	}
 }
 
 func (h *Handler) Page(n string) http.HandlerFunc {
 	return h.Web(func(app *App, w http.ResponseWriter, r *http.Request) error {
 		t, ok := pages[n]
 		if !ok {
 			return impart.HTTPError{http.StatusNotFound, "Page not found."}
 		}
 
 		sp := pageForReq(app, r)
 
 		err := t.ExecuteTemplate(w, "base", sp)
 		if err != nil {
 			log.Error("Unable to render page: %v", err)
 		}
 		return err
 	}, UserLevelOptional)
 }
 
-func (h *Handler) WebErrors(f handlerFunc, ul UserLevel) http.HandlerFunc {
+func (h *Handler) WebErrors(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		// TODO: factor out this logic shared with Web()
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					u := getUserSession(h.app.App(), r)
 					username := "None"
 					if u != nil {
 						username = u.Username
 					}
 					log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 			}()
 
 			var session *sessions.Session
 			var err error
-			if ul != UserLevelNone {
+			if ul(h.app.App().cfg) != UserLevelNoneType {
 				session, err = h.sessionStore.Get(r, cookieName)
-				if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
+				if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
 					// Cookie is required, but we can ignore this error
-					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
+					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
 				}
 
 				_, gotUser := session.Values[cookieUserVal].(*User)
-				if ul == UserLevelNoneRequired && gotUser {
+				if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
 					to := correctPageFromLoginAttempt(r)
 					log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
 					err := impart.HTTPError{http.StatusFound, to}
 					status = err.Status
 					return err
-				} else if ul == UserLevelUser && !gotUser {
+				} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
 					log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
 					err := ErrNotLoggedIn
 					status = err.Status
 					return err
 				}
 			}
 
 			// TODO: pass User object to function
 			err = f(h.app.App(), w, r)
 			if err == nil {
 				status = 200
 			} else if httpErr, ok := err.(impart.HTTPError); ok {
 				status = httpErr.Status
 				if status < 300 || status > 399 {
 					addSessionFlash(h.app.App(), w, r, httpErr.Message, session)
 					return impart.HTTPError{http.StatusFound, r.Referer()}
 				}
 			} else {
 				e := fmt.Sprintf("[Web handler] 500: %v", err)
 				if !strings.HasSuffix(e, "write: broken pipe") {
 					log.Error(e)
 				} else {
 					log.Error(e)
 				}
 				log.Info("Web handler internal error render")
 				h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 				status = 500
 			}
 
 			return err
 		}())
 	}
 }
 
+func (h *Handler) CollectionPostOrStatic(w http.ResponseWriter, r *http.Request) {
+	if strings.Contains(r.URL.Path, ".") && !isRaw(r) {
+		start := time.Now()
+		status := 200
+		defer func() {
+			log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+		}()
+
+		// Serve static file
+		h.app.App().shttp.ServeHTTP(w, r)
+		return
+	}
+
+	h.Web(viewCollectionPost, UserLevelReader)(w, r)
+}
+
 // Web handles requests made in the web application. This provides user-
 // friendly HTML pages and actions that work in the browser.
-func (h *Handler) Web(f handlerFunc, ul UserLevel) http.HandlerFunc {
+func (h *Handler) Web(f handlerFunc, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					u := getUserSession(h.app.App(), r)
 					username := "None"
 					if u != nil {
 						username = u.Username
 					}
 					log.Error("User: %s\n\n%s: %s", username, e, debug.Stack())
 					log.Info("Web deferred internal error render")
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 			}()
 
-			if ul != UserLevelNone {
+			if ul(h.app.App().cfg) != UserLevelNoneType {
 				session, err := h.sessionStore.Get(r, cookieName)
-				if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
+				if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
 					// Cookie is required, but we can ignore this error
-					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
+					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
 				}
 
 				_, gotUser := session.Values[cookieUserVal].(*User)
-				if ul == UserLevelNoneRequired && gotUser {
+				if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
 					to := correctPageFromLoginAttempt(r)
 					log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
 					err := impart.HTTPError{http.StatusFound, to}
 					status = err.Status
 					return err
-				} else if ul == UserLevelUser && !gotUser {
+				} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
 					log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
 					err := ErrNotLoggedIn
 					status = err.Status
 					return err
 				}
 			}
 
 			// TODO: pass User object to function
 			err := f(h.app.App(), w, r)
 			if err == nil {
 				status = 200
 			} else if httpErr, ok := err.(impart.HTTPError); ok {
 				status = httpErr.Status
 			} else {
 				e := fmt.Sprintf("[Web handler] 500: %v", err)
 				log.Error(e)
 				log.Info("Web internal error render")
 				h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 				status = 500
 			}
 
 			return err
 		}())
 	}
 }
 
 func (h *Handler) All(f handlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleError(w, r, func() error {
 			// TODO: return correct "success" status
 			status := 200
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s:\n%s", e, debug.Stack())
 					impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
 					status = 500
 				}
 
 				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 			}()
 
 			// TODO: do any needed authentication
 
 			err := f(h.app.App(), w, r)
 			if err != nil {
 				if err, ok := err.(impart.HTTPError); ok {
 					status = err.Status
 				} else {
 					status = 500
 				}
 			}
 
 			return err
 		}())
 	}
 }
 
-func (h *Handler) Download(f dataHandlerFunc, ul UserLevel) http.HandlerFunc {
+func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		h.handleError(w, r, func() error {
+			status := 200
+			start := time.Now()
+
+			defer func() {
+				if e := recover(); e != nil {
+					log.Error("%s:\n%s", e, debug.Stack())
+					impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "Something didn't work quite right."})
+					status = 500
+				}
+
+				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
+			}()
+
+			if h.app.App().cfg.App.Private {
+				// This instance is private, so ensure it's being accessed by a valid user
+				// Check if authenticated with an access token
+				_, apiErr := optionalAPIAuth(h.app.App(), r)
+				if apiErr != nil {
+					if err, ok := apiErr.(impart.HTTPError); ok {
+						status = err.Status
+					} else {
+						status = 500
+					}
+
+					if apiErr == ErrNotLoggedIn {
+						// Fall back to web auth since there was no access token given
+						_, err := webAuth(h.app.App(), r)
+						if err != nil {
+							if err, ok := apiErr.(impart.HTTPError); ok {
+								status = err.Status
+							} else {
+								status = 500
+							}
+							return err
+						}
+					} else {
+						return apiErr
+					}
+				}
+			}
+
+			err := f(h.app.App(), w, r)
+			if err != nil {
+				if err, ok := err.(impart.HTTPError); ok {
+					status = err.Status
+				} else {
+					status = 500
+				}
+			}
+
+			return err
+		}())
+	}
+}
+
+func (h *Handler) Download(f dataHandlerFunc, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			var status int
 			start := time.Now()
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 			}()
 
 			data, filename, err := f(h.app.App(), w, r)
 			if err != nil {
 				if err, ok := err.(impart.HTTPError); ok {
 					status = err.Status
 				} else {
 					status = 500
 				}
 				return err
 			}
 
 			ext := ".json"
 			ct := "application/json"
 			if strings.HasSuffix(r.URL.Path, ".csv") {
 				ext = ".csv"
 				ct = "text/csv"
 			} else if strings.HasSuffix(r.URL.Path, ".zip") {
 				ext = ".zip"
 				ct = "application/zip"
 			}
 			w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s%s", filename, ext))
 			w.Header().Set("Content-Type", ct)
 			w.Header().Set("Content-Length", strconv.Itoa(len(data)))
 			fmt.Fprint(w, string(data))
 
 			status = 200
 			return nil
 		}())
 	}
 }
 
-func (h *Handler) Redirect(url string, ul UserLevel) http.HandlerFunc {
+func (h *Handler) Redirect(url string, ul UserLevelFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			start := time.Now()
 
 			var status int
-			if ul != UserLevelNone {
+			if ul(h.app.App().cfg) != UserLevelNoneType {
 				session, err := h.sessionStore.Get(r, cookieName)
-				if err != nil && (ul == UserLevelNoneRequired || ul == UserLevelUser) {
+				if err != nil && (ul(h.app.App().cfg) == UserLevelNoneRequiredType || ul(h.app.App().cfg) == UserLevelUserType) {
 					// Cookie is required, but we can ignore this error
-					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul, err)
+					log.Error("Handler: Unable to get session (for user permission %d); ignoring: %v", ul(h.app.App().cfg), err)
 				}
 
 				_, gotUser := session.Values[cookieUserVal].(*User)
-				if ul == UserLevelNoneRequired && gotUser {
+				if ul(h.app.App().cfg) == UserLevelNoneRequiredType && gotUser {
 					to := correctPageFromLoginAttempt(r)
 					log.Info("Handler: Required NO user, but got one. Redirecting to %s", to)
 					err := impart.HTTPError{http.StatusFound, to}
 					status = err.Status
 					return err
-				} else if ul == UserLevelUser && !gotUser {
+				} else if ul(h.app.App().cfg) == UserLevelUserType && !gotUser {
 					log.Info("Handler: Required a user, but DIDN'T get one. Sending not logged in.")
 					err := ErrNotLoggedIn
 					status = err.Status
 					return err
 				}
 			}
 
 			status = sendRedirect(w, http.StatusFound, url)
 
 			log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 
 			return nil
 		}())
 	}
 }
 
 func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err error) {
 	if err == nil {
 		return
 	}
 
 	if err, ok := err.(impart.HTTPError); ok {
 		if err.Status >= 300 && err.Status < 400 {
 			sendRedirect(w, err.Status, err.Message)
 			return
 		} else if err.Status == http.StatusUnauthorized {
 			q := ""
 			if r.URL.RawQuery != "" {
 				q = url.QueryEscape("?" + r.URL.RawQuery)
 			}
 			sendRedirect(w, http.StatusFound, "/login?to="+r.URL.Path+q)
 			return
 		} else if err.Status == http.StatusGone {
 			w.WriteHeader(err.Status)
 			p := &struct {
 				page.StaticPage
 				Content *template.HTML
 			}{
 				StaticPage: pageForReq(h.app.App(), r),
 			}
 			if err.Message != "" {
 				co := template.HTML(err.Message)
 				p.Content = &co
 			}
 			h.errors.Gone.ExecuteTemplate(w, "base", p)
 			return
 		} else if err.Status == http.StatusNotFound {
 			w.WriteHeader(err.Status)
 			h.errors.NotFound.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 			return
 		} else if err.Status == http.StatusInternalServerError {
 			w.WriteHeader(err.Status)
 			log.Info("handleHTTPErorr internal error render")
 			h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 			return
 		} else if err.Status == http.StatusAccepted {
 			impart.WriteSuccess(w, "", err.Status)
 			return
 		} else {
 			p := &struct {
 				page.StaticPage
 				Title   string
 				Content template.HTML
 			}{
 				pageForReq(h.app.App(), r),
 				fmt.Sprintf("Uh oh (%d)", err.Status),
 				template.HTML(fmt.Sprintf("<p style=\"text-align: center\" class=\"introduction\">%s</p>", err.Message)),
 			}
 			h.errors.Blank.ExecuteTemplate(w, "base", p)
 			return
 		}
 		impart.WriteError(w, err)
 		return
 	}
 
 	impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
 }
 
 func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error) {
 	if err == nil {
 		return
 	}
 
 	if err, ok := err.(impart.HTTPError); ok {
 		if err.Status >= 300 && err.Status < 400 {
 			sendRedirect(w, err.Status, err.Message)
 			return
 		}
 
 		//		if strings.Contains(r.Header.Get("Accept"), "text/html") {
 		impart.WriteError(w, err)
 		//		}
 		return
 	}
 
 	if IsJSON(r.Header.Get("Content-Type")) {
 		impart.WriteError(w, impart.HTTPError{http.StatusInternalServerError, "This is an unhelpful error message for a miscellaneous internal error."})
 		return
 	}
 	h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 }
 
 func correctPageFromLoginAttempt(r *http.Request) string {
 	to := r.FormValue("to")
 	if to == "" {
 		to = "/"
 	} else if !strings.HasPrefix(to, "/") {
 		to = "/" + to
 	}
 	return to
 }
 
 func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		h.handleHTTPError(w, r, func() error {
 			status := 200
 			start := time.Now()
 
 			defer func() {
 				if e := recover(); e != nil {
 					log.Error("Handler.LogHandlerFunc\n\n%s: %s", e, debug.Stack())
 					h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
 					status = 500
 				}
 
 				// TODO: log actual status code returned
 				log.Info("\"%s %s\" %d %s \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent())
 			}()
 
+			if h.app.App().cfg.App.Private {
+				// This instance is private, so ensure it's being accessed by a valid user
+				// Check if authenticated with an access token
+				_, apiErr := optionalAPIAuth(h.app.App(), r)
+				if apiErr != nil {
+					if err, ok := apiErr.(impart.HTTPError); ok {
+						status = err.Status
+					} else {
+						status = 500
+					}
+
+					if apiErr == ErrNotLoggedIn {
+						// Fall back to web auth since there was no access token given
+						_, err := webAuth(h.app.App(), r)
+						if err != nil {
+							if err, ok := apiErr.(impart.HTTPError); ok {
+								status = err.Status
+							} else {
+								status = 500
+							}
+							return err
+						}
+					} else {
+						return apiErr
+					}
+				}
+			}
+
 			f(w, r)
 
 			return nil
 		}())
 	}
 }
 
 func sendRedirect(w http.ResponseWriter, code int, location string) int {
 	w.Header().Set("Location", location)
 	w.WriteHeader(code)
 	return code
 }
diff --git a/page/page.go b/page/page.go
index 4560382..2af5322 100644
--- a/page/page.go
+++ b/page/page.go
@@ -1,44 +1,45 @@
 /*
  * 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 page provides mechanisms and data for generating a WriteFreely page.
 package page
 
 import (
 	"github.com/writeas/writefreely/config"
 	"strings"
 )
 
 type StaticPage struct {
 	// App configuration
 	config.AppCfg
 	Version   string
 	HeaderNav bool
 
 	// Request values
-	Path     string
-	Username string
-	Values   map[string]string
-	Flashes  []string
+	Path          string
+	Username      string
+	Values        map[string]string
+	Flashes       []string
+	CanViewReader bool
 }
 
 // SanitizeHost alters the StaticPage to contain a real hostname. This is
 // especially important for the Tor hidden service, as it can be served over
 // proxies, messing up the apparent hostname.
 func (sp *StaticPage) SanitizeHost(cfg *config.Config) {
 	if cfg.Server.HiddenHost != "" && strings.HasPrefix(sp.Host, cfg.Server.HiddenHost) {
 		sp.Host = cfg.Server.HiddenHost
 	}
 }
 
 func (sp StaticPage) OfficialVersion() string {
 	p := strings.Split(sp.Version, "-")
 	return p[0]
 }
diff --git a/posts.go b/posts.go
index 228c7e5..d4be947 100644
--- a/posts.go
+++ b/posts.go
@@ -1,1401 +1,1411 @@
 /*
  * 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"
 	"encoding/json"
 	"fmt"
 	"html/template"
 	"net/http"
 	"regexp"
 	"strings"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/guregu/null"
 	"github.com/guregu/null/zero"
 	"github.com/kylemcc/twitter-text-go/extract"
 	"github.com/microcosm-cc/bluemonday"
 	stripmd "github.com/writeas/go-strip-markdown"
 	"github.com/writeas/impart"
 	"github.com/writeas/monday"
 	"github.com/writeas/slug"
 	"github.com/writeas/web-core/activitystreams"
 	"github.com/writeas/web-core/bots"
 	"github.com/writeas/web-core/converter"
 	"github.com/writeas/web-core/i18n"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/web-core/tags"
 	"github.com/writeas/writefreely/page"
 	"github.com/writeas/writefreely/parse"
 )
 
 const (
 	// Post ID length bounds
 	minIDLen      = 10
 	maxIDLen      = 10
 	userPostIDLen = 10
 	postIDLen     = 10
 
 	postMetaDateFormat = "2006-01-02 15:04:05"
 )
 
 type (
 	AnonymousPost struct {
 		ID          string
 		Content     string
 		HTMLContent template.HTML
 		Font        string
 		Language    string
 		Direction   string
 		Title       string
 		GenTitle    string
 		Description string
 		Author      string
 		Views       int64
 		IsPlainText bool
 		IsCode      bool
 		IsLinkable  bool
 	}
 
 	AuthenticatedPost struct {
 		ID  string `json:"id" schema:"id"`
 		Web bool   `json:"web" schema:"web"`
 		*SubmittedPost
 	}
 
 	// SubmittedPost represents a post supplied by a client for publishing or
 	// updating. Since Title and Content can be updated to "", they are
 	// pointers that can be easily tested to detect changes.
 	SubmittedPost struct {
 		Slug     *string                  `json:"slug" schema:"slug"`
 		Title    *string                  `json:"title" schema:"title"`
 		Content  *string                  `json:"body" schema:"body"`
 		Font     string                   `json:"font" schema:"font"`
 		IsRTL    converter.NullJSONBool   `json:"rtl" schema:"rtl"`
 		Language converter.NullJSONString `json:"lang" schema:"lang"`
 		Created  *string                  `json:"created" schema:"created"`
 	}
 
 	// Post represents a post as found in the database.
 	Post struct {
 		ID             string        `db:"id" json:"id"`
 		Slug           null.String   `db:"slug" json:"slug,omitempty"`
 		Font           string        `db:"text_appearance" json:"appearance"`
 		Language       zero.String   `db:"language" json:"language"`
 		RTL            zero.Bool     `db:"rtl" json:"rtl"`
 		Privacy        int64         `db:"privacy" json:"-"`
 		OwnerID        null.Int      `db:"owner_id" json:"-"`
 		CollectionID   null.Int      `db:"collection_id" json:"-"`
 		PinnedPosition null.Int      `db:"pinned_position" json:"-"`
 		Created        time.Time     `db:"created" json:"created"`
 		Updated        time.Time     `db:"updated" json:"updated"`
 		ViewCount      int64         `db:"view_count" json:"-"`
 		Title          zero.String   `db:"title" json:"title"`
 		HTMLTitle      template.HTML `db:"title" json:"-"`
 		Content        string        `db:"content" json:"body"`
 		HTMLContent    template.HTML `db:"content" json:"-"`
 		HTMLExcerpt    template.HTML `db:"content" json:"-"`
 		Tags           []string      `json:"tags"`
 		Images         []string      `json:"images,omitempty"`
 
 		OwnerName string `json:"owner,omitempty"`
 	}
 
 	// PublicPost holds properties for a publicly returned post, i.e. a post in
 	// a context where the viewer may not be the owner. As such, sensitive
 	// metadata for the post is hidden and properties supporting the display of
 	// the post are added.
 	PublicPost struct {
 		*Post
 		IsSubdomain bool           `json:"-"`
 		IsTopLevel  bool           `json:"-"`
 		DisplayDate string         `json:"-"`
 		Views       int64          `json:"views"`
 		Owner       *PublicUser    `json:"-"`
 		IsOwner     bool           `json:"-"`
 		Collection  *CollectionObj `json:"collection,omitempty"`
 	}
 
 	RawPost struct {
 		Id, Slug     string
 		Title        string
 		Content      string
 		Views        int64
 		Font         string
 		Created      time.Time
 		IsRTL        sql.NullBool
 		Language     sql.NullString
 		OwnerID      int64
 		CollectionID sql.NullInt64
 
 		Found bool
 		Gone  bool
 	}
 
 	AnonymousAuthPost struct {
 		ID    string `json:"id"`
 		Token string `json:"token"`
 	}
 	ClaimPostRequest struct {
 		*AnonymousAuthPost
 		CollectionAlias  string `json:"collection"`
 		CreateCollection bool   `json:"create_collection"`
 
 		// Generated properties
 		Slug string `json:"-"`
 	}
 	ClaimPostResult struct {
 		ID           string      `json:"id,omitempty"`
 		Code         int         `json:"code,omitempty"`
 		ErrorMessage string      `json:"error_msg,omitempty"`
 		Post         *PublicPost `json:"post,omitempty"`
 	}
 )
 
 func (p *Post) Direction() string {
 	if p.RTL.Valid {
 		if p.RTL.Bool {
 			return "rtl"
 		}
 		return "ltr"
 	}
 	return "auto"
 }
 
 // DisplayTitle dynamically generates a title from the Post's contents if it
 // doesn't already have an explicit title.
 func (p *Post) DisplayTitle() string {
 	if p.Title.String != "" {
 		return p.Title.String
 	}
 	t := friendlyPostTitle(p.Content, p.ID)
 	return t
 }
 
 // PlainDisplayTitle dynamically generates a title from the Post's contents if it
 // doesn't already have an explicit title.
 func (p *Post) PlainDisplayTitle() string {
 	if t := stripmd.Strip(p.DisplayTitle()); t != "" {
 		return t
 	}
 	return p.ID
 }
 
 // FormattedDisplayTitle dynamically generates a title from the Post's contents if it
 // doesn't already have an explicit title.
 func (p *Post) FormattedDisplayTitle() template.HTML {
 	if p.HTMLTitle != "" {
 		return p.HTMLTitle
 	}
 	return template.HTML(p.DisplayTitle())
 }
 
 // Summary gives a shortened summary of the post based on the post's title,
 // especially for display in a longer list of posts. It extracts a summary for
 // posts in the Title\n\nBody format, returning nothing if the entire was short
 // enough that the extracted title == extracted summary.
 func (p Post) Summary() string {
 	if p.Content == "" {
 		return ""
 	}
 	// Strip out HTML
 	p.Content = bluemonday.StrictPolicy().Sanitize(p.Content)
 	// and Markdown
 	p.Content = stripmd.Strip(p.Content)
 
 	title := p.Title.String
 	var desc string
 	if title == "" {
 		// No title, so generate one
 		title = friendlyPostTitle(p.Content, p.ID)
 		desc = postDescription(p.Content, title, p.ID)
 		if desc == title {
 			return ""
 		}
 		return desc
 	}
 
 	return shortPostDescription(p.Content)
 }
 
 // Excerpt shows any text that comes before a (more) tag.
 // TODO: use HTMLExcerpt in templates instead of this method
 func (p *Post) Excerpt() template.HTML {
 	return p.HTMLExcerpt
 }
 
 func (p *Post) CreatedDate() string {
 	return p.Created.Format("2006-01-02")
 }
 
 func (p *Post) Created8601() string {
 	return p.Created.Format("2006-01-02T15:04:05Z")
 }
 
 func (p *Post) IsScheduled() bool {
 	return p.Created.After(time.Now())
 }
 
 func (p *Post) HasTag(tag string) bool {
 	// Regexp looks for tag and has a non-capturing group at the end looking
 	// for the end of the word.
 	// Assisted by: https://stackoverflow.com/a/35192941/1549194
 	hasTag, _ := regexp.MatchString("#"+tag+`(?:[[:punct:]]|\s|\z)`, p.Content)
 	return hasTag
 }
 
 func (p *Post) HasTitleLink() bool {
 	if p.Title.String == "" {
 		return false
 	}
 	hasLink, _ := regexp.MatchString(`([^!]+|^)\[.+\]\(.+\)`, p.Title.String)
 	return hasLink
 }
 
 func handleViewPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	friendlyID := vars["post"]
 
+	// NOTE: until this is done better, be sure to keep this in parity with
+	// isRaw() and viewCollectionPost()
 	isJSON := strings.HasSuffix(friendlyID, ".json")
 	isXML := strings.HasSuffix(friendlyID, ".xml")
 	isCSS := strings.HasSuffix(friendlyID, ".css")
 	isMarkdown := strings.HasSuffix(friendlyID, ".md")
 	isRaw := strings.HasSuffix(friendlyID, ".txt") || isJSON || isXML || isCSS || isMarkdown
 
 	// Display reserved page if that is requested resource
 	if t, ok := pages[r.URL.Path[1:]+".tmpl"]; ok {
 		return handleTemplatedPage(app, w, r, t)
 	} else if (strings.Contains(r.URL.Path, ".") && !isRaw && !isMarkdown) || r.URL.Path == "/robots.txt" || r.URL.Path == "/manifest.json" {
 		// Serve static file
 		app.shttp.ServeHTTP(w, r)
 		return nil
 	}
 
 	// Display collection if this is a collection
 	c, _ := app.db.GetCollection(friendlyID)
 	if c != nil {
 		return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s/", friendlyID)}
 	}
 
 	// Normalize the URL, redirecting user to consistent post URL
 	if friendlyID != strings.ToLower(friendlyID) {
 		return impart.HTTPError{http.StatusMovedPermanently, fmt.Sprintf("/%s", strings.ToLower(friendlyID))}
 	}
 
 	ext := ""
 	if isRaw {
 		parts := strings.Split(friendlyID, ".")
 		friendlyID = parts[0]
 		if len(parts) > 1 {
 			ext = "." + parts[1]
 		}
 	}
 
 	var ownerID sql.NullInt64
 	var title string
 	var content string
 	var font string
 	var language []byte
 	var rtl []byte
 	var views int64
 	var post *AnonymousPost
 	var found bool
 	var gone bool
 
 	fixedID := slug.Make(friendlyID)
 	if fixedID != friendlyID {
 		return impart.HTTPError{http.StatusFound, fmt.Sprintf("/%s%s", fixedID, ext)}
 	}
 
 	err := app.db.QueryRow(fmt.Sprintf("SELECT owner_id, title, content, text_appearance, view_count, language, rtl FROM posts WHERE id = ?"), friendlyID).Scan(&ownerID, &title, &content, &font, &views, &language, &rtl)
 	switch {
 	case err == sql.ErrNoRows:
 		found = false
 
 		// Output the error in the correct format
 		if isJSON {
 			content = "{\"error\": \"Post not found.\"}"
 		} else if isRaw {
 			content = "Post not found."
 		} else {
 			return ErrPostNotFound
 		}
 	case err != nil:
 		found = false
 
 		log.Error("Post loading err: %s\n", err)
 		return ErrInternalGeneral
 	default:
 		found = true
 
 		var d string
 		if len(rtl) == 0 {
 			d = "auto"
 		} else if rtl[0] == 49 {
 			// TODO: find a cleaner way to get this (possibly NULL) value
 			d = "rtl"
 		} else {
 			d = "ltr"
 		}
 		generatedTitle := friendlyPostTitle(content, friendlyID)
 		sanitizedContent := content
 		if font != "code" {
 			sanitizedContent = template.HTMLEscapeString(content)
 		}
 		var desc string
 		if title == "" {
 			desc = postDescription(content, title, friendlyID)
 		} else {
 			desc = shortPostDescription(content)
 		}
 		post = &AnonymousPost{
 			ID:          friendlyID,
 			Content:     sanitizedContent,
 			Title:       title,
 			GenTitle:    generatedTitle,
 			Description: desc,
 			Author:      "",
 			Font:        font,
 			IsPlainText: isRaw,
 			IsCode:      font == "code",
 			IsLinkable:  font != "code",
 			Views:       views,
 			Language:    string(language),
 			Direction:   d,
 		}
 		if !isRaw {
 			post.HTMLContent = template.HTML(applyMarkdown([]byte(content), ""))
 		}
 	}
 
 	// Check if post has been unpublished
 	if content == "" {
 		gone = true
 
 		if isJSON {
 			content = "{\"error\": \"Post was unpublished.\"}"
 		} else if isCSS {
 			content = ""
 		} else if isRaw {
 			content = "Post was unpublished."
 		} else {
 			return ErrPostUnpublished
 		}
 	}
 
 	var u = &User{}
 	if isRaw {
 		contentType := "text/plain"
 		if isJSON {
 			contentType = "application/json"
 		} else if isCSS {
 			contentType = "text/css"
 		} else if isXML {
 			contentType = "application/xml"
 		} else if isMarkdown {
 			contentType = "text/markdown"
 		}
 		w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
 		if isMarkdown && post.Title != "" {
 			fmt.Fprintf(w, "%s\n", post.Title)
 			for i := 1; i <= len(post.Title); i++ {
 				fmt.Fprintf(w, "=")
 			}
 			fmt.Fprintf(w, "\n\n")
 		}
 		fmt.Fprint(w, content)
 
 		if !found {
 			return ErrPostNotFound
 		} else if gone {
 			return ErrPostUnpublished
 		}
 	} else {
 		var err error
 		page := struct {
 			*AnonymousPost
 			page.StaticPage
 			Username string
 			IsOwner  bool
 			SiteURL  string
 		}{
 			AnonymousPost: post,
 			StaticPage:    pageForReq(app, r),
 			SiteURL:       app.cfg.App.Host,
 		}
 		if u = getUserSession(app, r); u != nil {
 			page.Username = u.Username
 			page.IsOwner = ownerID.Valid && ownerID.Int64 == u.ID
 		}
 
 		err = templates["post"].ExecuteTemplate(w, "post", page)
 		if err != nil {
 			log.Error("Post template execute error: %v", err)
 		}
 	}
 
 	go func() {
 		if u != nil && ownerID.Valid && ownerID.Int64 == u.ID {
 			// Post is owned by someone; skip view increment since that person is viewing this post.
 			return
 		}
 		// Update stats for non-raw post views
 		if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
 			_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE id = ?", friendlyID)
 			if err != nil {
 				log.Error("Unable to update posts count: %v", err)
 			}
 		}
 	}()
 
 	return nil
 }
 
 // API v2 funcs
 // newPost creates a new post with or without an owning Collection.
 //
 // Endpoints:
 //   /posts
 //   /posts?collection={alias}
 // ? /collections/{alias}/posts
 func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 	vars := mux.Vars(r)
 	collAlias := vars["alias"]
 	if collAlias == "" {
 		collAlias = r.FormValue("collection")
 	}
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" {
 		// TODO: remove this
 		accessToken = r.FormValue("access_token")
 	}
 
 	// FIXME: determine web submission with Content-Type header
 	var u *User
 	var userID int64 = -1
 	var username string
 	if accessToken == "" {
 		u = getUserSession(app, r)
 		if u != nil {
 			userID = u.ID
 			username = u.Username
 		}
 	} else {
 		userID = app.db.GetUserID(accessToken)
 	}
 	if userID == -1 {
 		return ErrNotLoggedIn
 	}
 
 	if accessToken == "" && u == nil && collAlias != "" {
 		return impart.HTTPError{http.StatusBadRequest, "Parameter `access_token` required."}
 	}
 
 	// Get post data
 	var p *SubmittedPost
 	if reqJSON {
 		decoder := json.NewDecoder(r.Body)
 		err := decoder.Decode(&p)
 		if err != nil {
 			log.Error("Couldn't parse new post JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 		if p.Title == nil {
 			t := ""
 			p.Title = &t
 		}
 		if strings.TrimSpace(*(p.Content)) == "" {
 			return ErrNoPublishableContent
 		}
 	} else {
 		post := r.FormValue("body")
 		appearance := r.FormValue("font")
 		title := r.FormValue("title")
 		rtlValue := r.FormValue("rtl")
 		langValue := r.FormValue("lang")
 		if strings.TrimSpace(post) == "" {
 			return ErrNoPublishableContent
 		}
 
 		var isRTL, rtlValid bool
 		if rtlValue == "auto" && langValue != "" {
 			isRTL = i18n.LangIsRTL(langValue)
 			rtlValid = true
 		} else {
 			isRTL = rtlValue == "true"
 			rtlValid = rtlValue != "" && langValue != ""
 		}
 
 		// Create a new post
 		p = &SubmittedPost{
 			Title:    &title,
 			Content:  &post,
 			Font:     appearance,
 			IsRTL:    converter.NullJSONBool{sql.NullBool{Bool: isRTL, Valid: rtlValid}},
 			Language: converter.NullJSONString{sql.NullString{String: langValue, Valid: langValue != ""}},
 		}
 	}
 	if !p.isFontValid() {
 		p.Font = "norm"
 	}
 
 	var newPost *PublicPost = &PublicPost{}
 	var coll *Collection
 	var err error
 	if accessToken != "" {
 		newPost, err = app.db.CreateOwnedPost(p, accessToken, collAlias)
 	} else {
 		//return ErrNotLoggedIn
 		// TODO: verify user is logged in
 		var collID int64
 		if collAlias != "" {
 			coll, err = app.db.GetCollection(collAlias)
 			if err != nil {
 				return err
 			}
 			coll.hostName = app.cfg.App.Host
 			if coll.OwnerID != u.ID {
 				return ErrForbiddenCollection
 			}
 			collID = coll.ID
 		}
 		// TODO: return PublicPost from createPost
 		newPost.Post, err = app.db.CreatePost(userID, collID, p)
 	}
 	if err != nil {
 		return err
 	}
 	if coll != nil {
 		coll.ForPublic()
 		newPost.Collection = &CollectionObj{Collection: *coll}
 	}
 
 	newPost.extractData()
 	newPost.OwnerName = username
 
 	// Write success now
 	response := impart.WriteSuccess(w, newPost, http.StatusCreated)
 
-	if newPost.Collection != nil && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
+	if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
 		go federatePost(app, newPost, newPost.Collection.ID, false)
 	}
 
 	return response
 }
 
 func existingPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 	vars := mux.Vars(r)
 	postID := vars["post"]
 
 	p := AuthenticatedPost{ID: postID}
 	var err error
 
 	if reqJSON {
 		// Decode JSON request
 		decoder := json.NewDecoder(r.Body)
 		err = decoder.Decode(&p)
 		if err != nil {
 			log.Error("Couldn't parse post update JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 	} else {
 		err = r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse post update form request: %v\n", err)
 			return ErrBadFormData
 		}
 
 		// Can't decode to a nil SubmittedPost property, so create instance now
 		p.SubmittedPost = &SubmittedPost{}
 		err = app.formDecoder.Decode(&p, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode post update form request: %v\n", err)
 			return ErrBadFormData
 		}
 	}
 
 	if p.Web {
 		p.IsRTL.Valid = true
 	}
 
 	if p.SubmittedPost == nil {
 		return ErrPostNoUpdatableVals
 	}
 
 	// Ensure an access token was given
 	accessToken := r.Header.Get("Authorization")
 	// Get user's cookie session if there's no token
 	var u *User
 	//var username string
 	if accessToken == "" {
 		u = getUserSession(app, r)
 		if u != nil {
 			//username = u.Username
 		}
 	}
 	if u == nil && accessToken == "" {
 		return ErrNoAccessToken
 	}
 
 	// Get user ID from current session or given access token, if one was given.
 	var userID int64
 	if u != nil {
 		userID = u.ID
 	} else if accessToken != "" {
 		userID, err = AuthenticateUser(app.db, accessToken)
 		if err != nil {
 			return err
 		}
 	}
 
 	// Modify post struct
 	p.ID = postID
 
 	err = app.db.UpdateOwnedPost(&p, userID)
 	if err != nil {
 		if reqJSON {
 			return err
 		}
 
 		if err, ok := err.(impart.HTTPError); ok {
 			addSessionFlash(app, w, r, err.Message, nil)
 		} else {
 			addSessionFlash(app, w, r, err.Error(), nil)
 		}
 	}
 
 	var pRes *PublicPost
 	pRes, err = app.db.GetPost(p.ID, 0)
 	if reqJSON {
 		if err != nil {
 			return err
 		}
 		pRes.extractData()
 	}
 
 	if pRes.CollectionID.Valid {
 		coll, err := app.db.GetCollectionBy("id = ?", pRes.CollectionID.Int64)
-		if err == nil && app.cfg.App.Federation {
+		if err == nil && !app.cfg.App.Private && app.cfg.App.Federation {
 			coll.hostName = app.cfg.App.Host
 			pRes.Collection = &CollectionObj{Collection: *coll}
 			go federatePost(app, pRes, pRes.Collection.ID, true)
 		}
 	}
 
 	// Write success now
 	if reqJSON {
 		return impart.WriteSuccess(w, pRes, http.StatusOK)
 	}
 
 	addSessionFlash(app, w, r, "Changes saved.", nil)
 	collectionAlias := vars["alias"]
 	redirect := "/" + postID + "/meta"
 	if collectionAlias != "" {
 		collPre := "/" + collectionAlias
 		if app.cfg.App.SingleUser {
 			collPre = ""
 		}
 		redirect = collPre + "/" + pRes.Slug.String + "/edit/meta"
 	} else {
 		if app.cfg.App.SingleUser {
 			redirect = "/d" + redirect
 		}
 	}
 	w.Header().Set("Location", redirect)
 	w.WriteHeader(http.StatusFound)
 
 	return nil
 }
 
 func deletePost(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	friendlyID := vars["post"]
 	editToken := r.FormValue("token")
 
 	var ownerID int64
 	var u *User
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" && editToken == "" {
 		u = getUserSession(app, r)
 		if u == nil {
 			return ErrNoAccessToken
 		}
 	}
 
 	var res sql.Result
 	var t *sql.Tx
 	var err error
 	var collID sql.NullInt64
 	var coll *Collection
 	var pp *PublicPost
 	if editToken != "" {
 		// TODO: SELECT owner_id, as well, and return appropriate error if NULL instead of running two queries
 		var dummy int64
 		err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ?", friendlyID).Scan(&dummy)
 		switch {
 		case err == sql.ErrNoRows:
 			return impart.HTTPError{http.StatusNotFound, "Post not found."}
 		}
 		err = app.db.QueryRow("SELECT 1 FROM posts WHERE id = ? AND owner_id IS NULL", friendlyID).Scan(&dummy)
 		switch {
 		case err == sql.ErrNoRows:
 			// Post already has an owner. This could provide a bad experience
 			// for the user, but it's more important to ensure data isn't lost
 			// unexpectedly. So prevent deletion via token.
 			return impart.HTTPError{http.StatusConflict, "This post belongs to some user (hopefully yours). Please log in and delete it from that user's account."}
 		}
 		res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND modify_token = ? AND owner_id IS NULL", friendlyID, editToken)
 	} else if accessToken != "" || u != nil {
 		// Caller provided some way to authenticate; assume caller expects the
 		// post to be deleted based on a specific post owner, thus we should
 		// return corresponding errors.
 		if accessToken != "" {
 			ownerID = app.db.GetUserID(accessToken)
 			if ownerID == -1 {
 				return ErrBadAccessToken
 			}
 		} else {
 			ownerID = u.ID
 		}
 
 		// TODO: don't make two queries
 		var realOwnerID sql.NullInt64
 		err = app.db.QueryRow("SELECT collection_id, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&collID, &realOwnerID)
 		if err != nil {
 			return err
 		}
 		if !collID.Valid {
 			// There's no collection; simply delete the post
 			res, err = app.db.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
 		} else {
 			// Post belongs to a collection; do any additional clean up
 			coll, err = app.db.GetCollectionBy("id = ?", collID.Int64)
 			if err != nil {
 				log.Error("Unable to get collection: %v", err)
 				return err
 			}
 			if app.cfg.App.Federation {
 				// First fetch full post for federation
 				pp, err = app.db.GetOwnedPost(friendlyID, ownerID)
 				if err != nil {
 					log.Error("Unable to get owned post: %v", err)
 					return err
 				}
 				collObj := &CollectionObj{Collection: *coll}
 				pp.Collection = collObj
 			}
 
 			t, err = app.db.Begin()
 			if err != nil {
 				log.Error("No begin: %v", err)
 				return err
 			}
 			res, err = t.Exec("DELETE FROM posts WHERE id = ? AND owner_id = ?", friendlyID, ownerID)
 		}
 	} else {
 		return impart.HTTPError{http.StatusBadRequest, "No authenticated user or post token given."}
 	}
 	if err != nil {
 		return err
 	}
 
 	affected, err := res.RowsAffected()
 	if err != nil {
 		if t != nil {
 			t.Rollback()
 			log.Error("Rows affected err! Rolling back")
 		}
 		return err
 	} else if affected == 0 {
 		if t != nil {
 			t.Rollback()
 			log.Error("No rows affected! Rolling back")
 		}
 		return impart.HTTPError{http.StatusForbidden, "Post not found, or you're not the owner."}
 	}
 	if t != nil {
 		t.Commit()
 	}
-	if coll != nil && app.cfg.App.Federation {
+	if coll != nil && !app.cfg.App.Private && app.cfg.App.Federation {
 		go deleteFederatedPost(app, pp, collID.Int64)
 	}
 
 	return impart.HTTPError{Status: http.StatusNoContent}
 }
 
 // addPost associates a post with the authenticated user.
 func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var ownerID int64
 
 	// Authenticate user
 	at := r.Header.Get("Authorization")
 	if at != "" {
 		ownerID = app.db.GetUserID(at)
 		if ownerID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 		ownerID = u.ID
 	}
 
 	// Parse claimed posts in format:
 	// [{"id": "...", "token": "..."}]
 	var claims *[]ClaimPostRequest
 	decoder := json.NewDecoder(r.Body)
 	err := decoder.Decode(&claims)
 	if err != nil {
 		return ErrBadJSONArray
 	}
 
 	vars := mux.Vars(r)
 	collAlias := vars["alias"]
 
 	// Update all given posts
 	res, err := app.db.ClaimPosts(ownerID, collAlias, claims)
 	if err != nil {
 		return err
 	}
 
-	if app.cfg.App.Federation {
+	if !app.cfg.App.Private && app.cfg.App.Federation {
 		for _, pRes := range *res {
 			if pRes.Code != http.StatusOK {
 				continue
 			}
 			if !pRes.Post.Created.After(time.Now()) {
 				pRes.Post.Collection.hostName = app.cfg.App.Host
 				go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
 			}
 		}
 	}
 	return impart.WriteSuccess(w, res, http.StatusOK)
 }
 
 func dispersePost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var ownerID int64
 
 	// Authenticate user
 	at := r.Header.Get("Authorization")
 	if at != "" {
 		ownerID = app.db.GetUserID(at)
 		if ownerID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 		ownerID = u.ID
 	}
 
 	// Parse posts in format:
 	// ["..."]
 	var postIDs []string
 	decoder := json.NewDecoder(r.Body)
 	err := decoder.Decode(&postIDs)
 	if err != nil {
 		return ErrBadJSONArray
 	}
 
 	// Update all given posts
 	res, err := app.db.DispersePosts(ownerID, postIDs)
 	if err != nil {
 		return err
 	}
 	return impart.WriteSuccess(w, res, http.StatusOK)
 }
 
 type (
 	PinPostResult struct {
 		ID           string `json:"id,omitempty"`
 		Code         int    `json:"code,omitempty"`
 		ErrorMessage string `json:"error_msg,omitempty"`
 	}
 )
 
 // pinPost pins a post to a blog
 func pinPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var userID int64
 
 	// Authenticate user
 	at := r.Header.Get("Authorization")
 	if at != "" {
 		userID = app.db.GetUserID(at)
 		if userID == -1 {
 			return ErrBadAccessToken
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 		userID = u.ID
 	}
 
 	// Parse request
 	var posts []struct {
 		ID       string `json:"id"`
 		Position int64  `json:"position"`
 	}
 	decoder := json.NewDecoder(r.Body)
 	err := decoder.Decode(&posts)
 	if err != nil {
 		return ErrBadJSONArray
 	}
 
 	// Validate data
 	vars := mux.Vars(r)
 	collAlias := vars["alias"]
 
 	coll, err := app.db.GetCollection(collAlias)
 	if err != nil {
 		return err
 	}
 	if coll.OwnerID != userID {
 		return ErrForbiddenCollection
 	}
 
 	// Do (un)pinning
 	isPinning := r.URL.Path[strings.LastIndex(r.URL.Path, "/"):] == "/pin"
 	res := []PinPostResult{}
 	for _, p := range posts {
 		err = app.db.UpdatePostPinState(isPinning, p.ID, coll.ID, userID, p.Position)
 		ppr := PinPostResult{ID: p.ID}
 		if err != nil {
 			ppr.Code = http.StatusInternalServerError
 			// TODO: set error messsage
 		} else {
 			ppr.Code = http.StatusOK
 		}
 		res = append(res, ppr)
 	}
 	return impart.WriteSuccess(w, res, http.StatusOK)
 }
 
 func fetchPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	var collID int64
 	var coll *Collection
 	var err error
 	vars := mux.Vars(r)
 	if collAlias := vars["alias"]; collAlias != "" {
 		// Fetch collection information, since an alias is provided
 		coll, err = app.db.GetCollection(collAlias)
 		if err != nil {
 			return err
 		}
 		coll.hostName = app.cfg.App.Host
 		_, err = apiCheckCollectionPermissions(app, r, coll)
 		if err != nil {
 			return err
 		}
 		collID = coll.ID
 	}
 
 	p, err := app.db.GetPost(vars["post"], collID)
 	if err != nil {
 		return err
 	}
 
 	p.extractData()
 
 	accept := r.Header.Get("Accept")
 	if strings.Contains(accept, "application/activity+json") {
 		// Fetch information about the collection this belongs to
 		if coll == nil && p.CollectionID.Valid {
 			coll, err = app.db.GetCollectionByID(p.CollectionID.Int64)
 			if err != nil {
 				return err
 			}
 		}
 		if coll == nil {
 			// This is a draft post; 404 for now
 			// TODO: return ActivityObject
 			return impart.HTTPError{http.StatusNotFound, ""}
 		}
 
 		p.Collection = &CollectionObj{Collection: *coll}
 		po := p.ActivityObject()
 		po.Context = []interface{}{activitystreams.Namespace}
 		return impart.RenderActivityJSON(w, po, http.StatusOK)
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func fetchPostProperty(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	p, err := app.db.GetPostProperty(vars["post"], 0, vars["property"])
 	if err != nil {
 		return err
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func (p *Post) processPost() PublicPost {
 	res := &PublicPost{Post: p, Views: 0}
 	res.Views = p.ViewCount
 	// TODO: move to own function
 	loc := monday.FuzzyLocale(p.Language.String)
 	res.DisplayDate = monday.Format(p.Created, monday.LongFormatsByLocale[loc], loc)
 
 	return *res
 }
 
 func (p *PublicPost) CanonicalURL() string {
 	if p.Collection == nil || p.Collection.Alias == "" {
 		return p.Collection.hostName + "/" + p.ID
 	}
 	return p.Collection.CanonicalURL() + p.Slug.String
 }
 
 func (p *PublicPost) ActivityObject() *activitystreams.Object {
 	o := activitystreams.NewArticleObject()
 	o.ID = p.Collection.FederatedAPIBase() + "api/posts/" + p.ID
 	o.Published = p.Created
 	o.URL = p.CanonicalURL()
 	o.AttributedTo = p.Collection.FederatedAccount()
 	o.CC = []string{
 		p.Collection.FederatedAccount() + "/followers",
 	}
 	o.Name = p.DisplayTitle()
 	if p.HTMLContent == template.HTML("") {
 		p.formatContent(false)
 	}
 	o.Content = string(p.HTMLContent)
 	if p.Language.Valid {
 		o.ContentMap = map[string]string{
 			p.Language.String: string(p.HTMLContent),
 		}
 	}
 	if len(p.Tags) == 0 {
 		o.Tag = []activitystreams.Tag{}
 	} else {
 		var tagBaseURL string
 		if isSingleUser {
 			tagBaseURL = p.Collection.CanonicalURL() + "tag:"
 		} else {
 			tagBaseURL = fmt.Sprintf("%s/%s/tag:", p.Collection.hostName, p.Collection.Alias)
 		}
 		for _, t := range p.Tags {
 			o.Tag = append(o.Tag, activitystreams.Tag{
 				Type: activitystreams.TagHashtag,
 				HRef: tagBaseURL + t,
 				Name: "#" + t,
 			})
 		}
 	}
 	return o
 }
 
 // TODO: merge this into getSlugFromPost or phase it out
 func getSlug(title, lang string) string {
 	return getSlugFromPost("", title, lang)
 }
 
 func getSlugFromPost(title, body, lang string) string {
 	if title == "" {
 		title = postTitle(body, body)
 	}
 	title = parse.PostLede(title, false)
 	// Truncate lede if needed
 	title, _ = parse.TruncToWord(title, 80)
 	if lang != "" && len(lang) == 2 {
 		return slug.MakeLang(title, lang)
 	}
 	return slug.Make(title)
 }
 
 // isFontValid returns whether or not the submitted post's appearance is valid.
 func (p *SubmittedPost) isFontValid() bool {
 	validFonts := map[string]bool{
 		"norm": true,
 		"sans": true,
 		"mono": true,
 		"wrap": true,
 		"code": true,
 	}
 
 	_, valid := validFonts[p.Font]
 	return valid
 }
 
 func getRawPost(app *App, friendlyID string) *RawPost {
 	var content, font, title string
 	var isRTL sql.NullBool
 	var lang sql.NullString
 	var ownerID sql.NullInt64
 	var created time.Time
 
 	err := app.db.QueryRow("SELECT title, content, text_appearance, language, rtl, created, owner_id FROM posts WHERE id = ?", friendlyID).Scan(&title, &content, &font, &lang, &isRTL, &created, &ownerID)
 	switch {
 	case err == sql.ErrNoRows:
 		return &RawPost{Content: "", Found: false, Gone: false}
 	case err != nil:
 		return &RawPost{Content: "", Found: true, Gone: false}
 	}
 
 	return &RawPost{Title: title, Content: content, Font: font, Created: created, IsRTL: isRTL, Language: lang, OwnerID: ownerID.Int64, Found: true, Gone: content == ""}
 
 }
 
 // TODO; return a Post!
 func getRawCollectionPost(app *App, slug, collAlias string) *RawPost {
 	var id, title, content, font string
 	var isRTL sql.NullBool
 	var lang sql.NullString
 	var created time.Time
 	var ownerID null.Int
 	var views int64
 	var err error
 
 	if app.cfg.App.SingleUser {
 		err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = 1", slug).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
 	} else {
 		err = app.db.QueryRow("SELECT id, title, content, text_appearance, language, rtl, view_count, created, owner_id FROM posts WHERE slug = ? AND collection_id = (SELECT id FROM collections WHERE alias = ?)", slug, collAlias).Scan(&id, &title, &content, &font, &lang, &isRTL, &views, &created, &ownerID)
 	}
 	switch {
 	case err == sql.ErrNoRows:
 		return &RawPost{Content: "", Found: false, Gone: false}
 	case err != nil:
 		return &RawPost{Content: "", Found: true, Gone: false}
 	}
 
 	return &RawPost{
 		Id:       id,
 		Slug:     slug,
 		Title:    title,
 		Content:  content,
 		Font:     font,
 		Created:  created,
 		IsRTL:    isRTL,
 		Language: lang,
 		OwnerID:  ownerID.Int64,
 		Found:    true,
 		Gone:     content == "",
 		Views:    views,
 	}
 }
 
+func isRaw(r *http.Request) bool {
+	vars := mux.Vars(r)
+	slug := vars["slug"]
+
+	// NOTE: until this is done better, be sure to keep this in parity with
+	// isRaw in viewCollectionPost() and handleViewPost()
+	isJSON := strings.HasSuffix(slug, ".json")
+	isXML := strings.HasSuffix(slug, ".xml")
+	isMarkdown := strings.HasSuffix(slug, ".md")
+	return strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
+}
+
 func viewCollectionPost(app *App, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	slug := vars["slug"]
 
+	// NOTE: until this is done better, be sure to keep this in parity with
+	// isRaw() and handleViewPost()
 	isJSON := strings.HasSuffix(slug, ".json")
 	isXML := strings.HasSuffix(slug, ".xml")
 	isMarkdown := strings.HasSuffix(slug, ".md")
 	isRaw := strings.HasSuffix(slug, ".txt") || isJSON || isXML || isMarkdown
 
-	if strings.Contains(r.URL.Path, ".") && !isRaw {
-		// Serve static file
-		app.shttp.ServeHTTP(w, r)
-		return nil
-	}
-
 	cr := &collectionReq{}
 	err := processCollectionRequest(cr, vars, w, r)
 	if err != nil {
 		return err
 	}
 
 	// Check for hellbanned users
 	u, err := checkUserForCollection(app, cr, r, true)
 	if err != nil {
 		return err
 	}
 
 	// Normalize the URL, redirecting user to consistent post URL
 	if slug != strings.ToLower(slug) {
 		loc := fmt.Sprintf("/%s", strings.ToLower(slug))
 		if !app.cfg.App.SingleUser {
 			loc = "/" + cr.alias + loc
 		}
 		return impart.HTTPError{http.StatusMovedPermanently, loc}
 	}
 
 	// Display collection if this is a collection
 	var c *Collection
 	if app.cfg.App.SingleUser {
 		c, err = app.db.GetCollectionByID(1)
 	} else {
 		c, err = app.db.GetCollection(cr.alias)
 	}
 	if err != nil {
 		if err, ok := err.(impart.HTTPError); ok {
 			if err.Status == http.StatusNotFound {
 				// Redirect if necessary
 				newAlias := app.db.GetCollectionRedirect(cr.alias)
 				if newAlias != "" {
 					return impart.HTTPError{http.StatusFound, "/" + newAlias + "/" + slug}
 				}
 			}
 		}
 		return err
 	}
 	c.hostName = app.cfg.App.Host
 
 	// Check collection permissions
 	if c.IsPrivate() && (u == nil || u.ID != c.OwnerID) {
 		return ErrPostNotFound
 	}
 	if c.IsProtected() && ((u == nil || u.ID != c.OwnerID) && !isAuthorizedForCollection(app, c.Alias, r)) {
 		return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/?g=" + slug}
 	}
 
 	cr.isCollOwner = u != nil && c.OwnerID == u.ID
 
 	if isRaw {
 		slug = strings.Split(slug, ".")[0]
 	}
 
 	// Fetch extra data about the Collection
 	// TODO: refactor out this logic, shared in collection.go:fetchCollection()
 	coll := &CollectionObj{Collection: *c}
 	owner, err := app.db.GetUserByID(coll.OwnerID)
 	if err != nil {
 		// Log the error and just continue
 		log.Error("Error getting user for collection: %v", err)
 	} else {
 		coll.Owner = owner
 	}
 
 	p, err := app.db.GetPost(slug, coll.ID)
 	if err != nil {
 		if err == ErrCollectionPageNotFound && slug == "feed" {
 			// User tried to access blog feed without a trailing slash, and
 			// there's no post with a slug "feed"
 			return impart.HTTPError{http.StatusFound, c.CanonicalURL() + "/feed/"}
 		}
 		return err
 	}
 	p.IsOwner = owner != nil && p.OwnerID.Valid && owner.ID == p.OwnerID.Int64
 	p.Collection = coll
 	p.IsTopLevel = app.cfg.App.SingleUser
 
 	// Check if post has been unpublished
 	if p.Content == "" {
 		return impart.HTTPError{http.StatusGone, "Post was unpublished."}
 	}
 
 	// Serve collection post
 	if isRaw {
 		contentType := "text/plain"
 		if isJSON {
 			contentType = "application/json"
 		} else if isXML {
 			contentType = "application/xml"
 		} else if isMarkdown {
 			contentType = "text/markdown"
 		}
 		w.Header().Set("Content-Type", fmt.Sprintf("%s; charset=utf-8", contentType))
 		if isMarkdown && p.Title.String != "" {
 			fmt.Fprintf(w, "# %s\n\n", p.Title.String)
 		}
 		fmt.Fprint(w, p.Content)
 	} else if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
 		p.extractData()
 		ap := p.ActivityObject()
 		ap.Context = []interface{}{activitystreams.Namespace}
 		return impart.RenderActivityJSON(w, ap, http.StatusOK)
 	} else {
 		p.extractData()
 		p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
 		// TODO: move this to function
 		p.formatContent(cr.isCollOwner)
 		tp := struct {
 			*PublicPost
 			page.StaticPage
 			IsOwner        bool
 			IsPinned       bool
 			IsCustomDomain bool
 			PinnedPosts    *[]PublicPost
 		}{
 			PublicPost:     p,
 			StaticPage:     pageForReq(app, r),
 			IsOwner:        cr.isCollOwner,
 			IsCustomDomain: cr.isCustomDomain,
 		}
 		tp.PinnedPosts, _ = app.db.GetPinnedPosts(coll)
 		tp.IsPinned = len(*tp.PinnedPosts) > 0 && PostsContains(tp.PinnedPosts, p)
 		if err := templates["collection-post"].ExecuteTemplate(w, "post", tp); err != nil {
 			log.Error("Error in collection-post template: %v", err)
 		}
 	}
 
 	go func() {
 		if p.OwnerID.Valid {
 			// Post is owned by someone. Don't update stats if owner is viewing the post.
 			if u != nil && p.OwnerID.Int64 == u.ID {
 				return
 			}
 		}
 		// Update stats for non-raw post views
 		if !isRaw && r.Method != "HEAD" && !bots.IsBot(r.UserAgent()) {
 			_, err := app.db.Exec("UPDATE posts SET view_count = view_count + 1 WHERE slug = ? AND collection_id = ?", slug, coll.ID)
 			if err != nil {
 				log.Error("Unable to update posts count: %v", err)
 			}
 		}
 	}()
 
 	return nil
 }
 
 // TODO: move this to utils after making it more generic
 func PostsContains(sl *[]PublicPost, s *PublicPost) bool {
 	for _, e := range *sl {
 		if e.ID == s.ID {
 			return true
 		}
 	}
 	return false
 }
 
 func (p *Post) extractData() {
 	p.Tags = tags.Extract(p.Content)
 	p.extractImages()
 }
 
 func (rp *RawPost) UserFacingCreated() string {
 	return rp.Created.Format(postMetaDateFormat)
 }
 
 func (rp *RawPost) Created8601() string {
 	return rp.Created.Format("2006-01-02T15:04:05Z")
 }
 
 var imageURLRegex = regexp.MustCompile(`(?i)^https?:\/\/[^ ]*\.(gif|png|jpg|jpeg|image)$`)
 
 func (p *Post) extractImages() {
 	matches := extract.ExtractUrls(p.Content)
 	urls := map[string]bool{}
 	for i := range matches {
 		u := matches[i].Text
 		if !imageURLRegex.MatchString(u) {
 			continue
 		}
 		urls[u] = true
 	}
 
 	resURLs := make([]string, 0)
 	for k := range urls {
 		resURLs = append(resURLs, k)
 	}
 	p.Images = resURLs
 }
diff --git a/routes.go b/routes.go
index 13dd3a5..724c532 100644
--- a/routes.go
+++ b/routes.go
@@ -1,207 +1,204 @@
 /*
  * 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 (
 	"github.com/gorilla/mux"
 	"github.com/writeas/go-webfinger"
 	"github.com/writeas/web-core/log"
 	"github.com/writefreely/go-nodeinfo"
 	"net/http"
 	"path/filepath"
 	"strings"
 )
 
 // InitStaticRoutes adds routes for serving static files.
 // TODO: this should just be a func, not method
 func (app *App) InitStaticRoutes(r *mux.Router) {
 	// Handle static files
 	fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
 	app.shttp = http.NewServeMux()
 	app.shttp.Handle("/", fs)
 	r.PathPrefix("/").Handler(fs)
 }
 
 // InitRoutes adds dynamic routes for the given mux.Router.
 func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
 	// Create handler
 	handler := NewWFHandler(apper)
 
 	// Set up routes
 	hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:]
 	if apper.App().cfg.App.SingleUser {
 		hostSubroute = "{domain}"
 	} else {
 		if strings.HasPrefix(hostSubroute, "localhost") {
 			hostSubroute = "localhost"
 		}
 	}
 
 	if apper.App().cfg.App.SingleUser {
 		log.Info("Adding %s routes (single user)...", hostSubroute)
 	} else {
 		log.Info("Adding %s routes (multi-user)...", hostSubroute)
 	}
 
 	// Primary app routes
 	write := r.PathPrefix("/").Subrouter()
 
 	// Federation endpoint configurations
 	wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg})
 	wf.NoTLSHandler = nil
 
 	// Federation endpoints
 	// host-meta
-	write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelOptional))
+	write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader))
 	// webfinger
 	write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger)))
 	// nodeinfo
 	niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg)
 	ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db})
 	write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
 	write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
 
 	// Set up dyamic page handlers
 	// Handle auth
 	auth := write.PathPrefix("/api/auth/").Subrouter()
 	if apper.App().cfg.App.OpenRegistration {
 		auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST")
 	}
 	auth.HandleFunc("/login", handler.All(login)).Methods("POST")
 	auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST")
 	auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE")
 
 	// Handle logged in user sections
 	me := write.PathPrefix("/me").Subrouter()
 	me.HandleFunc("/", handler.Redirect("/me", UserLevelUser))
 	me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET")
 	me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
 	me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
 	me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
 	me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
 	me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
 	me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
 	me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
 	me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
 	me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
 
 	write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET")
 	apiMe := write.PathPrefix("/api/me/").Subrouter()
 	apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET")
 	apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET")
 	apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
 	apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
 	apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
 	apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
 
 	// Sign up validation
 	write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
 
 	// Handle collections
 	write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
 	apiColls := write.PathPrefix("/api/collections/").Subrouter()
-	apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(fetchCollection)).Methods("GET")
+	apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET")
 	apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
-	apiColls.HandleFunc("/{alias}/posts", handler.All(fetchCollectionPosts)).Methods("GET")
+	apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
-	apiColls.HandleFunc("/{alias}/posts/{post}", handler.All(fetchPost)).Methods("GET")
+	apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST")
-	apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.All(fetchPostProperty)).Methods("GET")
+	apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
-	apiColls.HandleFunc("/{alias}/outbox", handler.All(handleFetchCollectionOutbox)).Methods("GET")
-	apiColls.HandleFunc("/{alias}/following", handler.All(handleFetchCollectionFollowing)).Methods("GET")
-	apiColls.HandleFunc("/{alias}/followers", handler.All(handleFetchCollectionFollowers)).Methods("GET")
+	apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
+	apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
+	apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET")
 
 	// Handle posts
 	write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
 	posts := write.PathPrefix("/api/posts/").Subrouter()
-	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(fetchPost)).Methods("GET")
+	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET")
 	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT")
 	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE")
-	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.All(fetchPostProperty)).Methods("GET")
+	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
 	posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
 	posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
 
 	write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
 	write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
 
 	write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
 	write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
 	write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
 	write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET")
 	write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
 	write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
 	write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
 
 	// Handle special pages first
 	write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
 	write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelNoneRequired)).Methods("GET")
 	// TODO: show a reader-specific 404 page if the function is disabled
-	// TODO: change this based on configuration for either public or private-to-this-instance
-	readPerm := UserLevelOptional
-
-	write.HandleFunc("/read", handler.Web(viewLocalTimeline, readPerm))
-	RouteRead(handler, readPerm, write.PathPrefix("/read").Subrouter())
+	write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
+	RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
 
 	draftEditPrefix := ""
 	if apper.App().cfg.App.SingleUser {
 		draftEditPrefix = "/d"
 		write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
 	} else {
 		write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
 	}
 
 	// All the existing stuff
 	write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
 	write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET")
 	// Collections
 	if apper.App().cfg.App.SingleUser {
 		RouteCollections(handler, write.PathPrefix("/").Subrouter())
 	} else {
-		write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelOptional))
-		write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelOptional))
+		write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader))
+		write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader))
 		RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter())
 		// Posts
 	}
 	write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
 	write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
 	return r
 }
 
 func RouteCollections(handler *Handler, r *mux.Router) {
-	r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelOptional))
-	r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelOptional))
-	r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelOptional))
-	r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelOptional))
-	r.HandleFunc("/sitemap.xml", handler.All(handleViewSitemap))
-	r.HandleFunc("/feed/", handler.All(ViewFeed))
-	r.HandleFunc("/{slug}", handler.Web(viewCollectionPost, UserLevelOptional))
+	r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
+	r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
+	r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
+	r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
+	r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
+	r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
+	r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
 	r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
 	r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
-	r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelOptional)).Methods("GET")
+	r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET")
 }
 
-func RouteRead(handler *Handler, readPerm UserLevel, r *mux.Router) {
+func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) {
 	r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm))
 	r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm))
 	r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm))
 	r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm))
 	r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm))
 	r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm))
 	r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm))
 }
diff --git a/templates/base.tmpl b/templates/base.tmpl
index 01ead5b..775dac9 100644
--- a/templates/base.tmpl
+++ b/templates/base.tmpl
@@ -1,57 +1,57 @@
 {{define "base"}}<!DOCTYPE HTML>
 <html>
 	<head>
 		{{ template "head" . }}
 		<link rel="stylesheet" type="text/css" href="{{.Host}}/css/{{.Theme}}.css" />
 		<link rel="shortcut icon" href="{{.Host}}/favicon.ico" />
 		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
 
 		<meta name="application-name" content="{{.SiteName}}">
 		<meta name="application-url" content="{{.Host}}">
 		<meta property="og:site_name" content="{{.SiteName}}" />
 	</head>
 	<body {{template "body-attrs" .}}>
 		<div id="overlay"></div>
 		<header>
 			<h2><a href="/">{{.SiteName}}</a></h2>
 			{{if not .SingleUser}}
 			<nav id="user-nav">
 				<nav class="tabs">
 					<a href="/about"{{if eq .Path "/about"}} class="selected"{{end}}>About</a>
-					{{if and (not .SingleUser) .LocalTimeline}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
+					{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read"{{if eq .Path "/read"}} class="selected"{{end}}>Reader</a>{{end}}
 					{{if and (not .SingleUser) (not .Username)}}<a href="/login"{{if eq .Path "/login"}} class="selected"{{end}}>Log in</a>{{end}}
 				</nav>
 			</nav>
 			{{end}}
 		</header>
 
 		<div id="official-writing">
 			{{ template "content" . }}
 		</div>
 
 		{{ template "footer" . }}
 		
 		{{if not .JSDisabled}}
 		<script type="text/javascript">
 		{{if .WebFonts}}
 		try { // Google Fonts
 		  WebFontConfig = {
 			custom: { families: [ 'Lora:400,700:latin', 'Open+Sans:400,700:latin' ], urls: [ '/css/fonts.css' ] }
 		  };
 		  (function() {
 			var wf = document.createElement('script');
 			wf.src = '/js/webfont.js';
 			wf.type = 'text/javascript';
 			wf.async = 'true';
 			var s = document.getElementsByTagName('script')[0];
 			s.parentNode.insertBefore(wf, s);
 			})();
 		} catch (e) { /* ¯\_(ツ)_/¯ */ }
 		{{end}}
 		</script>
 		{{else}}
 			{{if .WebFonts}}<link href="{{.Host}}/css/fonts.css" rel="stylesheet" type="text/css" />{{end}}
 		{{end}}
 	</body>
 </html>{{end}}
 {{define "body-attrs"}}{{end}}
diff --git a/templates/include/footer.tmpl b/templates/include/footer.tmpl
index 252699d..32a7e68 100644
--- a/templates/include/footer.tmpl
+++ b/templates/include/footer.tmpl
@@ -1,36 +1,36 @@
 {{define "footer"}}
 		<footer{{if not .SingleUser}} class="contain-me"{{end}}>
 			<hr />
 			{{if .SingleUser}}
 			<nav>
 				<a class="home" href="/">{{.SiteName}}</a>
 				<a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a>
 				<a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">developers</a>
 				<a href="https://github.com/writeas/writefreely">source code</a>
 				<a href="https://writefreely.org">writefreely {{.Version}}</a>
 			</nav>
 			{{else}}
 			<div class="marketing-section">
 				<div class="clearfix blurbs">
 					<div class="half">
 						<h3><a class="home" href="/">{{.SiteName}}</a></h3>
 						<ul>
 							<li><a href="/about">about</a></li>
-							{{if and (not .SingleUser) .LocalTimeline}}<a href="/read">reader</a>{{end}}
+							{{if and (and (not .SingleUser) .LocalTimeline) .CanViewReader}}<a href="/read">reader</a>{{end}}
 							<li><a href="/privacy">privacy</a></li>
 						</ul>
 					</div>
 					<div class="half">
 						<h3><a href="https://writefreely.org" style="color:#444;text-transform:lowercase;">WriteFreely</a></h3>
 						<ul>
 							<li><a href="https://writefreely.org/guide/{{.OfficialVersion}}" target="guide">writer's guide</a></li>
 							<li><a href="https://developers.write.as/" title="Build on WriteFreely with our open developer API.">developers</a></li>
 							<li><a href="https://github.com/writeas/writefreely">source code</a></li>
 							<li style="margin-top:0.8em">{{.Version}}</li>
 						</ul>
 					</div>
 				</div>
 			</div>
 			{{end}}
 		</footer>
 {{end}}
diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl
index 112e020..08e8886 100644
--- a/templates/user/collection.tmpl
+++ b/templates/user/collection.tmpl
@@ -1,236 +1,236 @@
 {{define "upgrade"}}
 <p><a href="/me/plan?to=/me/c/{{.Alias}}">Upgrade</a> for <span>$40 / year</span> to edit.</p>
 {{end}}
 
 {{define "collection"}}
 {{template "header" .}}
 
 <div class="content-container snug">
 	<div id="overlay"></div>
 
 	<h2>Customize {{.DisplayTitle}} <a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">view blog</a></h2>
 
 	{{if .Flashes}}<ul class="errors">
 		{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
 	</ul>{{end}}
 
 <form name="customize-form" action="/api/collections/{{.Alias}}" method="post" onsubmit="return disableSubmit()">
 <div id="collection-options">
 	<div style="text-align:center">
 		<h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder="Title" /></h1>
 		<p><input type="text" name="description" id="description" value="{{.Description}}" placeholder="Description" /></p>
 	</div>
 
 	<div class="option">
 		<h2><a name="preferred-url"></a>URL</h2>
 		<div class="section">
 			{{if eq .Alias .Username}}<p style="font-size: 0.8em">This blog uses your username in its URL{{if .Federation}} and fediverse handle{{end}}. You can change it in your <a href="/me/settings">Account Settings</a>.</p>{{end}}
 			<ul style="list-style:none">
 				<li>
 					{{.FriendlyHost}}/<strong>{{.Alias}}</strong>/
 				</li>
 				<li>
 					<strong id="normal-handle-env" class="fedi-handle" {{if not .Federation}}style="display:none"{{end}}>@<span id="fedi-handle">{{.Alias}}</span>@<span id="fedi-domain">{{.FriendlyHost}}</span></strong>
 				</li>
 			</ul>
 		</div>
 	</div>
 
 	<div class="option">
 		<h2>Publicity</h2>
 		<div class="section">
 			<ul style="list-style:none">
 				<li>
 					<label><input type="radio" name="visibility" id="visibility-unlisted" value="0" {{if .IsUnlisted}}checked="checked"{{end}} />
 						Unlisted
 					</label>
-					<p>This blog is visible to anyone with its link.</p>
+					<p>This blog is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.</p>
 				</li>
 				<li>
 				<label class="option-text"><input type="radio" name="visibility" id="visibility-private" value="2" {{if .IsPrivate}}checked="checked"{{end}} />
 						Private
 					</label>
 					<p>Only you may read this blog (while you're logged in).</p>
 				</li>
 				<li>
 					<label class="option-text"><input type="radio" name="visibility" id="visibility-protected" value="4" {{if .IsProtected}}checked="checked"{{end}} />
 						Password-protected: <input type="password" class="low-profile" name="password" id="collection-pass" autocomplete="new-password" placeholder="{{if .IsProtected}}xxxxxxxxxxxxxxxx{{else}}a memorable password{{end}}" />
 					</label>
 					<p>A password is required to read this blog.</p>
 				</li>
 				<li>
 					<label class="option-text{{if not .LocalTimeline}} disabled{{end}}"><input type="radio" name="visibility" id="visibility-public" value="1" {{if .IsPublic}}checked="checked"{{end}} {{if not .LocalTimeline}}disabled="disabled"{{end}} />
 						Public
 					</label>
-					{{if .LocalTimeline}}<p>This blog is displayed on the public <a href="/read">reader</a>, and to anyone with its link.</p>
+					{{if .LocalTimeline}}<p>This blog is displayed on the public <a href="/read">reader</a>, and is visible to {{if .Private}}any registered user on this instance{{else}}anyone with its link{{end}}.</p>
 					{{else}}<p>The public reader is currently turned off for this community.</p>{{end}}
 				</li>
 			</ul>
 		</div>
 	</div>
 
 	<div class="option">
 		<h2>Display Format</h2>
 		<div class="section">
 			<p class="explain">Customize how your posts display on your page.
 			</p>
 			<ul style="list-style:none">
 				<li>
 					<label><input type="radio" name="format" id="format-blog" value="blog" {{if or (not .Format) (eq .Format "blog")}}checked="checked"{{end}} />
 						Blog
 					</label>
 					<p>Dates are shown. Latest posts listed first.</p>
 				</li>
 				<li>
 					<label class="option-text"><input type="radio" name="format" id="format-novel" value="novel" {{if eq .Format "novel"}}checked="checked"{{end}} />
 						Novel
 					</label>
 					<p>No dates shown. Oldest posts first.</p>
 				</li>
 				<li>
 					<label class="option-text"><input type="radio" name="format" id="format-notebook" value="notebook" {{if eq .Format "notebook"}}checked="checked"{{end}} />
 						Notebook
 					</label>
 					<p>No dates shown. Latest posts first.</p>
 				</li>
 			</ul>
 		</div>
 	</div>
 
 	<div class="option">
 		<h2>Text Rendering</h2>
 		<div class="section">
 			<p class="explain">Customize how plain text renders on your blog.</p>
 			<ul style="list-style:none">
 				<li>
 					<label class="option-text disabled"><input type="checkbox" name="markdown" checked="checked" disabled />
 						Markdown
 					</label>
 				</li>
 				<li>
 					<label><input type="checkbox" name="mathjax" {{if .RenderMathJax}}checked="checked"{{end}} />
 						MathJax
 					</label>
 				</li>
 			</ul>
 		</div>
 	</div>
 
 	<div class="option">
 		<h2>Custom CSS</h2>
 		<div class="section">
 			<textarea id="css-editor" class="section codable" name="style_sheet">{{.StyleSheet}}</textarea>
 			<p class="explain">See our guide on <a href="https://guides.write.as/customizing/#custom-css">customization</a>.</p>
 		</div>
 	</div>
 
 	<div class="option" style="text-align: center; margin-top: 4em;">
 		<input type="submit" id="save-changes" value="Save changes" />
 		<p><a href="{{if .SingleUser}}/{{else}}/{{.Alias}}/{{end}}">View Blog</a></p>
 		{{if ne .Alias .Username}}<p><a class="danger" href="#modal-delete" onclick="promptDelete();">Delete Blog...</a></p>{{end}}
 	</div>
 </div>
 </form>
 </div>
 
 		<div id="modal-delete" class="modal">
 			<h2>Are you sure you want to delete this blog?</h2>
 			<div class="body short">
 				<p style="text-align:left">This will permanently erase <strong>{{.DisplayTitle}}</strong> ({{.FriendlyHost}}/{{.Alias}}) from the internet. Any posts on this blog will be saved and made into drafts (found on your <a href="/me/posts/">Drafts</a> page).</p>
 				<p>If you're sure you want to delete this blog, enter its name in the box below and press <strong>Delete</strong>.</p>
 
 				<ul id="delete-errors" class="errors"></ul>
 
 				<input id="confirm-text" placeholder="{{.Alias}}" type="text" class="boxy" style="margin-top: 0.5em;" />
 				<div style="text-align:right; margin-top: 1em;">
 					<a id="cancel-delete" style="margin-right:2em" href="#">Cancel</a>
 					<button id="btn-delete" class="danger" onclick="deleteBlog(); return false;">Delete</button>
 				</div>
 			</div>
 		</div>
 
 <script src="/js/h.js"></script>
 <script src="/js/ace.js" type="text/javascript" charset="utf-8"></script>
 <script>
 // Begin shared modal code
 function showModal(id) {
 	document.getElementById('overlay').style.display = 'block';
 	document.getElementById('modal-'+id).style.display = 'block';
 }
 
 var closeModals = function(e) {
 	e.preventDefault();
 	document.getElementById('overlay').style.display = 'none';
 	var modals = document.querySelectorAll('.modal');
 	for (var i=0; i<modals.length; i++) {
 		modals[i].style.display = 'none'; 
 	}
 };
 H.getEl('overlay').on('click', closeModals);
 H.getEl('cancel-delete').on('click', closeModals);
 // end
 var deleteBlog = function(e) {
 	if (document.getElementById('confirm-text').value != '{{.Alias}}') {
 		document.getElementById('delete-errors').innerHTML = '<li class="urgent">Enter <strong>{{.Alias}}</strong> in the box below.</li>';
 		return;
 	}
 	// Clear errors
 	document.getElementById('delete-errors').innerHTML = '';
 	document.getElementById('btn-delete').innerHTML = 'Deleting...';
 
 	var http = new XMLHttpRequest();
 	var url = "/api/collections/{{.Alias}}?web=1";
 	http.open("DELETE", url, true);
 	http.setRequestHeader("Content-type", "application/json");
 	http.onreadystatechange = function() {
 		if (http.readyState == 4) {
 			if (http.status == 204) {
 				window.location = '/me/c/';
 			} else {
 				var data = JSON.parse(http.responseText);
 				document.getElementById('delete-errors').innerHTML = '<li class="urgent">'+data.error_msg+'</li>';
 				document.getElementById('btn-delete').innerHTML = 'Delete';
 			}
 		}
 	};
 	http.send(null);
 };
 
 function createHidden(theForm, key, value) {
     var input = document.createElement('input');
     input.type = 'hidden';
     input.name = key;
     input.value = value;
     theForm.appendChild(input);
 }
 function disableSubmit() {
 	var $form = document.forms['customize-form'];
 	createHidden($form, 'style_sheet', cssEditor.getSession().getValue());
 	var $btn = document.getElementById("save-changes");
 	$btn.value = "Saving changes...";
 	$btn.disabled = true;
 	return true;
 }
 function promptDelete() {
 	showModal("delete");
 }
 
 var $fediDomain = document.getElementById('fedi-domain');
 var $fediCustomDomain = document.getElementById('fedi-custom-domain');
 var $customDomain = document.getElementById('domain-alias');
 var $customHandleEnv = document.getElementById('custom-handle-env');
 var $normalHandleEnv = document.getElementById('normal-handle-env');
 
 var opt = {
 	showLineNumbers: false,
 	showPrintMargin: 0,
 };
 var theme = "ace/theme/chrome";
 var cssEditor = ace.edit("css-editor");
 cssEditor.setTheme(theme);
 cssEditor.session.setMode("ace/mode/css");
 cssEditor.setOptions(opt);
 </script>
 
 {{template "footer" .}}
 {{end}}