From 08eb8f733413a5b9c3d25328e60746f3019ec7c8 Mon Sep 17 00:00:00 2001
From: RandomChars <random@chars.jp>
Date: Sat, 20 Nov 2021 09:16:14 +0900
Subject: [PATCH] v2 package, rewrite filesystem store backend, declare Store
 interface and fix it up, version the API, remove client, eliminate logger
 dependency, eliminate configuration dependency, remove restart code

---
 .gitignore                             |   7 +-
 api.go                                 | 652 +-----------------------
 api/errors.go                          |  37 +-
 api/f.go                               |  81 +++
 api/paths.go                           |  32 +-
 api/types.go                           |  21 +
 api/v1.go                              | 668 +++++++++++++++++++++++++
 api/v2.go                              |  21 +
 backend/filesystem/image.go            | 553 ++++++++++++++++++++
 backend/filesystem/page.go             | 213 ++++++++
 {store => backend/filesystem}/paths.go |  49 +-
 backend/filesystem/secret.go           |  47 ++
 backend/filesystem/store.go            | 266 ++++++++++
 backend/filesystem/tag.go              | 148 ++++++
 backend/filesystem/user.go             | 381 ++++++++++++++
 cleanup.go                             |  21 +-
 client/image.go                        | 195 --------
 client/js/README                       |   1 -
 client/js/main.go                      |  13 -
 client/misc.go                         |   7 -
 client/remote.go                       |  71 ---
 client/request.go                      | 115 -----
 client/tag.go                          |  81 ---
 client/user.go                         | 107 ----
 config.go                              | 146 +++---
 go.mod                                 |  30 +-
 go.sum                                 | 360 +------------
 log.go                                 |  42 --
 main.go                                | 123 ++---
 recover.go                             |   7 +-
 restart.go                             |  23 -
 restart_windows.go                     |  27 -
 store/flake.go                         |  59 +++
 store/image.go                         | 514 -------------------
 store/misc.go                          |  41 --
 store/page.go                          | 183 -------
 store/secret.go                        |  56 ---
 store/spec.go                          |  54 ++
 store/store.go                         | 405 ++++-----------
 store/store_test.go                    |   1 +
 store/structs.go                       |  52 ++
 store/tag.go                           | 170 -------
 store/user.go                          | 313 ------------
 store/validation.go                    |  57 +++
 web.go                                 |  88 ++--
 45 files changed, 3001 insertions(+), 3537 deletions(-)
 create mode 100644 api/f.go
 create mode 100644 api/v1.go
 create mode 100644 api/v2.go
 create mode 100644 backend/filesystem/image.go
 create mode 100644 backend/filesystem/page.go
 rename {store => backend/filesystem}/paths.go (73%)
 create mode 100644 backend/filesystem/secret.go
 create mode 100644 backend/filesystem/store.go
 create mode 100644 backend/filesystem/tag.go
 create mode 100644 backend/filesystem/user.go
 delete mode 100644 client/image.go
 delete mode 100644 client/js/README
 delete mode 100644 client/js/main.go
 delete mode 100644 client/misc.go
 delete mode 100644 client/remote.go
 delete mode 100644 client/request.go
 delete mode 100644 client/tag.go
 delete mode 100644 client/user.go
 delete mode 100644 log.go
 delete mode 100644 restart.go
 delete mode 100644 restart_windows.go
 create mode 100644 store/flake.go
 delete mode 100644 store/image.go
 delete mode 100644 store/misc.go
 delete mode 100644 store/page.go
 delete mode 100644 store/secret.go
 create mode 100644 store/spec.go
 create mode 100644 store/store_test.go
 create mode 100644 store/structs.go
 delete mode 100644 store/tag.go
 delete mode 100644 store/user.go
 create mode 100644 store/validation.go

diff --git a/.gitignore b/.gitignore
index 9a8df94..e68131e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,11 +2,12 @@
 *.dylib
 *.test
 *.out
+*.core
 
+*.swp
 /.idea/
 /image-board
-/server.toml
+/server.conf
 /db
-/logs
 client.js*
-client.min.js*
\ No newline at end of file
+client.min.js*
diff --git a/api.go b/api.go
index 178c3f0..00970d7 100644
--- a/api.go
+++ b/api.go
@@ -1,648 +1,18 @@
 package main
 
-import (
-	"github.com/gin-gonic/gin"
-	log "github.com/sirupsen/logrus"
-	"io/ioutil"
-	"net/http"
-	"random.chars.jp/git/image-board/api"
-	"random.chars.jp/git/image-board/store"
-	"strconv"
-	"strings"
-	"unicode/utf8"
-)
+import "random.chars.jp/git/image-board/v2/api"
 
 func registerAPI() {
-	router.GET(api.Base, func(context *gin.Context) {
-		context.JSON(http.StatusOK, store.Info{
-			Revision:       instance.Revision,
-			Compat:         instance.Compat,
-			Register:       instance.Register,
-			InitialUser:    instance.InitialUser,
-			PermissionDir:  instance.PermissionDir,
-			PermissionFile: instance.PermissionFile,
-		})
-	})
-
-	router.GET(api.SingleUser, func(context *gin.Context) {
-		context.JSON(http.StatusOK, instance.SingleUser)
-	})
-
-	router.GET(api.Private, func(context *gin.Context) {
-		context.JSON(http.StatusOK, instance.Private)
-	})
-
-	router.GET(api.User, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.Users())
-	})
-
-	router.PUT(api.User, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		user, ok := getUser(context)
-		if !instance.Register {
-			if !ok {
-				context.JSON(http.StatusForbidden, api.Error{Error: "user registration disallowed"})
-				return
-			}
-
-			if !user.Privileged {
-				context.JSON(http.StatusForbidden, api.Denied)
-				return
-			}
-		}
-
-		var payload api.UserCreatePayload
-		if err := context.ShouldBindJSON(&payload); err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-		if !user.Privileged && payload.Privileged {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.UserAdd(payload.Username, payload.Password, payload.Privileged))
-	})
-
-	router.GET(api.UserThis, func(context *gin.Context) {
-		info, ok := getUser(context)
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-		context.JSON(http.StatusOK, api.UserPayload{
-			Username:   info.Username,
-			ID:         info.Snowflake,
-			Privileged: info.Privileged,
-		})
-	})
-
-	router.GET(api.UserField, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		info := instance.User(context.Param("flake"))
-		context.JSON(http.StatusOK, api.UserPayload{
-			Username:   info.Username,
-			ID:         info.Snowflake,
-			Privileged: info.Privileged,
-		})
-	})
-
-	router.PATCH(api.UserField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		flake := context.Param("flake")
-		if !info.Privileged && (info.Snowflake != flake) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-
-		var payload api.UserUpdatePayload
-		if err := context.ShouldBindJSON(&payload); err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-
-		if info.Privileged {
-			instance.UserPrivileged(flake, payload.Privileged)
-		}
-		instance.UserUsernameUpdate(flake, payload.Username)
-	})
-
-	router.DELETE(api.UserField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		flake := context.Param("flake")
-		if !info.Privileged && (info.Snowflake != flake) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		instance.UserDestroy(flake)
-	})
-
-	router.DELETE(api.UserPassword, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require privileged
-		if !ok || !info.Privileged {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		flake := context.Param("flake")
-		instance.UserPasswordUpdate(flake, "")
-		instance.UserSecretRegen(flake)
-	})
-
-	router.PUT(api.UserPassword, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		flake := context.Param("flake")
-		if !info.Privileged && (info.Snowflake != flake) {
-			context.JSON(http.StatusForbidden, api.Denied)
-		}
-
-		var newPass string
-		if payload, err := context.GetRawData(); err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		} else {
-			if !utf8.Valid(payload) {
-				context.JSON(http.StatusBadRequest, api.Error{Error: "invalid encoding"})
-				return
-			}
-			newPass = string(payload)
-			if len(newPass) > 8192 || strings.Contains(newPass, "\n") {
-				context.JSON(http.StatusBadRequest, api.Error{Error: "invalid password"})
-				return
-			}
-		}
-
-		if newPass == "" {
-			context.JSON(http.StatusBadRequest, api.Error{Error: "empty passwords are not allowed"})
-			return
-		}
-
-		instance.UserPasswordUpdate(info.Snowflake, newPass)
-		context.JSON(http.StatusOK, api.UserSecretPayload{Secret: instance.UserSecretRegen(info.Snowflake)})
-	})
-
-	router.GET(api.UsernameField, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		info := instance.UserUsername(context.Param("name"))
-		payload := api.UserPayload{
-			Username:   info.Username,
-			ID:         info.Snowflake,
-			Privileged: info.Privileged,
-		}
-		context.JSON(http.StatusOK, payload)
-	})
-
-	router.POST(api.UsernameAuth, func(context *gin.Context) {
-		var password string
-
-		if payload, err := context.GetRawData(); err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		} else {
-			password = string(payload)
-		}
-
-		username := context.Param("name")
-		if instance.UserUsernamePasswordValidate(username, password) {
-			context.JSON(http.StatusOK, api.UserSecretPayload{Secret: instance.UserUsername(username).Secret})
-		} else {
-			context.JSON(http.StatusForbidden, api.Denied)
-		}
-	})
-
-	router.GET(api.UserSecret, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		// Only allow lookup if user is current user or privileged
-		flake := context.Param("flake")
-		if !info.Privileged && (info.Snowflake != flake) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		context.JSON(http.StatusOK, api.UserSecretPayload{Secret: instance.User(flake).Secret})
-	})
-
-	router.PUT(api.UserSecret, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		// Only allow set if user is current user or privileged
-		flake := context.Param("flake")
-		if !info.Privileged && (info.Snowflake != flake) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		context.JSON(http.StatusOK, api.UserSecretPayload{Secret: instance.UserSecretRegen(flake)})
-	})
-
-	router.GET(api.UserImage, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.UserImages(context.Param("flake")))
-	})
-
-	router.GET(api.SearchField, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		tagsPayload := context.Param("tags")
-		tags := strings.Split(tagsPayload, "!")
-		context.JSON(http.StatusOK, instance.ImageSearch(tags))
-	})
-
-	router.GET(api.Image, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require privileged
-		if !ok || !info.Privileged {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.ImageSnowflakes())
-	})
-
-	router.POST(api.Image, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		payload, err := context.FormFile("image")
-		if err != nil {
-			context.JSON(http.StatusInternalServerError, api.Error{Error: err.Error()})
-			return
-		}
-		file, err := payload.Open()
-		if err != nil {
-			log.Errorf("Error while opening uploaded file %s, %s", payload.Filename, err)
-			context.JSON(http.StatusInternalServerError, api.Error{Error: err.Error()})
-			return
-		}
-		data, err := ioutil.ReadAll(file)
-		if err != nil {
-			log.Errorf("Error while reading uploaded file %s, %s", payload.Filename, err)
-			context.JSON(http.StatusInternalServerError, api.Error{Error: err.Error()})
-			return
-		}
-		image := instance.ImageAdd(data, info.Snowflake)
-		if image.Hash == "" {
-			context.JSON(http.StatusBadRequest, api.Error{Error: "invalid image"})
-			return
-		}
-		context.JSON(http.StatusOK, image)
-	})
-
-	router.GET(api.ImagePage, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		context.String(http.StatusOK, strconv.Itoa(instance.PageTotal(store.ImageRootPageVariant)))
-	})
-
-	router.GET(api.ImagePageField, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		param := context.Param("entry")
-		entry, err := strconv.Atoi(param)
-		if err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-		context.JSON(http.StatusOK, instance.Page(store.ImageRootPageVariant, entry))
-	})
-
-	router.GET(api.ImagePageImage, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		param := context.Param("entry")
-		entry, err := strconv.Atoi(param)
-		if err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-		context.JSON(http.StatusOK, instance.PageImages(store.ImageRootPageVariant, entry))
-	})
-
-	router.GET(api.ImageField, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.ImageSnowflake(context.Param("flake")))
-	})
-
-	router.PATCH(api.ImageField, func(context *gin.Context) {
-		user, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		info := instance.ImageSnowflake(context.Param("flake"))
-		if !user.Privileged && (info.User != user.Snowflake) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-
-		var payload api.ImageUpdatePayload
-		if err := context.ShouldBindJSON(&payload); err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-
-		instance.ImageUpdate(info.Hash, payload.Source, payload.Parent, payload.Commentary, payload.CommentaryTranslation)
-	})
-
-	router.DELETE(api.ImageField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		flake := context.Param("flake")
-		image := instance.ImageSnowflake(flake)
-		if !info.Privileged && (info.Snowflake != image.User) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		instance.ImageDestroy(image.Hash)
-	})
-
-	router.GET(api.ImageFile, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		flake := context.Param("flake")
-		image, data := instance.ImageData(instance.ImageSnowflakeHash(flake), false)
-		if image.Snowflake != flake {
-			context.JSON(http.StatusNotFound, api.Error{Error: "not found"})
-			return
-		}
-		context.Data(http.StatusOK, "image/"+image.Type, data)
-	})
-
-	router.GET(api.ImagePreview, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		flake := context.Param("flake")
-		image, data := instance.ImageData(instance.ImageSnowflakeHash(flake), true)
-		if image.Snowflake != flake {
-			context.JSON(http.StatusNotFound, api.Error{Error: "not found"})
-			return
-		}
-		context.Data(http.StatusOK, "image/jpeg", data)
-	})
-
-	router.GET(api.ImageTag, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.ImageTags(context.Param("flake")))
-	})
-
-	router.PUT(api.ImageTagField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		flake := context.Param("flake")
-		if !info.Privileged && (info.Snowflake != instance.ImageSnowflake(flake).User) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		instance.ImageTagAdd(flake, context.Param("tag"))
-	})
-
-	router.DELETE(api.ImageTagField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		flake := context.Param("flake")
-		if !info.Privileged && (info.Snowflake != instance.ImageSnowflake(flake).User) {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		instance.ImageTagRemove(flake, context.Param("tag"))
-	})
-
-	router.GET(api.Tag, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.Tags())
-	})
-
-	router.GET(api.TagField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		if !info.Privileged {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		context.JSON(http.StatusOK, instance.Tag(context.Param("tag")))
-	})
-
-	router.PUT(api.TagField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		if !info.Privileged {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		context.JSON(http.StatusOK, instance.TagCreate(context.Param("tag")))
-	})
-
-	router.DELETE(api.TagField, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		if !info.Privileged {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-		instance.TagDestroy(context.Param("tag"))
-	})
-
-	router.GET(api.TagPage, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		tag := context.Param("tag")
-		if !instance.MatchName(tag) {
-			context.JSON(http.StatusBadRequest, api.Denied)
-			return
-		}
-		context.String(http.StatusOK, strconv.Itoa(instance.PageTotal("tag_"+tag)))
-	})
-
-	router.GET(api.TagPageField, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		tag := context.Param("tag")
-		if !instance.MatchName(tag) {
-			context.JSON(http.StatusBadRequest, api.Denied)
-			return
-		}
-
-		param := context.Param("entry")
-		entry, err := strconv.Atoi(param)
-		if err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-		context.JSON(http.StatusOK, instance.Page("tag_"+tag, entry))
-	})
-
-	router.GET(api.TagPageImage, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		tag := context.Param("tag")
-		if !instance.MatchName(tag) {
-			context.JSON(http.StatusBadRequest, api.Denied)
-			return
-		}
-
-		param := context.Param("entry")
-		entry, err := strconv.Atoi(param)
-		if err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-		context.JSON(http.StatusOK, instance.PageImages("tag_"+tag, entry))
-	})
-
-	router.GET(api.TagInfo, func(context *gin.Context) {
-		if !privateAccessible(context) {
-			return
-		}
-
-		context.JSON(http.StatusOK, instance.TagInfo(context.Param("tag")))
-	})
-
-	router.PATCH(api.TagInfo, func(context *gin.Context) {
-		info, ok := getUser(context)
-
-		// Require sign in
-		if !ok {
-			context.JSON(http.StatusForbidden, api.Unauthorized)
-			return
-		}
-
-		if !info.Privileged {
-			context.JSON(http.StatusForbidden, api.Denied)
-			return
-		}
-
-		var payload api.TagUpdatePayload
-		if err := context.ShouldBindJSON(&payload); err != nil {
-			context.JSON(http.StatusBadRequest, api.Error{Error: err.Error()})
-			return
-		}
-		instance.TagType(context.Param("tag"), payload.Type)
-	})
-}
-
-func privateAccessible(context *gin.Context) bool {
-	if !instance.Private {
-		return true
+	s := &api.Server{
+		Instance: instance,
+		Engine:   router,
+		Config: &api.ServerConfig{
+			SingleUser: config.System.SingleUser,
+			Private:    config.System.Private,
+			Register:   config.System.Register,
+		},
 	}
 
-	if _, ok := getUser(context); !ok {
-		context.JSON(http.StatusForbidden, api.Denied)
-		return false
-	}
-
-	return true
-}
-
-func getUser(context *gin.Context) (store.User, bool) {
-	if instance.SingleUser {
-		return instance.User(instance.InitialUser), true
-	}
-	secret := context.GetHeader("secret")
-	info := instance.SecretLookup(secret)
-	if info.Secret != secret || info.Snowflake == "" {
-		return store.User{}, false
-	}
-	return info, true
+	s.V1()
+	s.V2()
 }
diff --git a/api/errors.go b/api/errors.go
index 7da482d..5d786d5 100644
--- a/api/errors.go
+++ b/api/errors.go
@@ -1,8 +1,41 @@
 package api
 
+import (
+	"github.com/gin-gonic/gin"
+	"net/http"
+	"random.chars.jp/git/image-board/v2/store"
+)
+
 type Error struct {
 	Error string `json:"error"`
 }
 
-var Unauthorized = Error{"not authorized"}
-var Denied = Error{"permission denied"}
+var (
+	ErrUnauthorized         = Error{"not authorized"}
+	ErrPermissionDenied     = Error{"permission denied"}
+	ErrRegistrationDisabled = Error{Error: "user creation disallowed"}
+)
+
+func doError(context *gin.Context, err error) bool {
+	switch err {
+	case nil:
+		return false
+	case store.ErrNoEntry:
+		context.JSON(http.StatusNotFound, Error{Error: err.Error()})
+	case store.ErrInvalidInput:
+		context.JSON(http.StatusBadRequest, Error{Error: err.Error()})
+	default:
+		context.JSON(http.StatusInternalServerError, Error{Error: err.Error()})
+	}
+	return true
+}
+
+func doErrorAPI(context *gin.Context, err error) bool {
+	switch err {
+	case nil:
+		return false
+	default:
+		context.JSON(http.StatusBadRequest, Error{Error: err.Error()})
+	}
+	return true
+}
diff --git a/api/f.go b/api/f.go
new file mode 100644
index 0000000..987053e
--- /dev/null
+++ b/api/f.go
@@ -0,0 +1,81 @@
+package api
+
+import (
+	"github.com/gin-gonic/gin"
+	"net/http"
+	"random.chars.jp/git/image-board/v2/store"
+)
+
+type Server struct {
+	Instance store.Store
+	Engine   *gin.Engine
+	Config   *ServerConfig
+}
+
+var initialUser *store.User
+
+func (s *Server) privateAccessible(context *gin.Context) bool {
+	if !s.Config.Private {
+		return true
+	}
+
+	return s.Instance.SecretValidate(context.GetHeader("secret"))
+}
+
+func (s *Server) getUser(context *gin.Context) (*store.User, error) {
+	if s.Config.SingleUser {
+		if initialUser != nil {
+			return initialUser, nil
+		}
+		if user, err := s.Instance.User(s.Instance.UserInitial()); err != nil {
+			return nil, err
+		} else {
+			initialUser = user
+			return user, nil
+		}
+	}
+
+	if info, err := s.Instance.SecretLookup(context.GetHeader("secret")); err != nil {
+		return nil, err
+	} else {
+		return info, nil
+	}
+}
+
+func (s *Server) mustGetUser(context *gin.Context) *store.User {
+	if user, err := s.getUser(context); err == store.ErrNoEntry {
+		context.JSON(http.StatusUnauthorized, ErrUnauthorized)
+		return nil
+	} else if doError(context, err) {
+		return nil
+	} else {
+		return user
+	}
+}
+
+func (s *Server) json(context *gin.Context) func(obj interface{}, err error) bool {
+	return func(obj interface{}, err error) bool {
+		if doError(context, err) {
+			return false
+		}
+		context.JSON(http.StatusOK, obj)
+		return true
+	}
+}
+
+func (s *Server) pageImages(variant string, entry uint64) ([]*store.Image, error) {
+	if page, err := s.Instance.Page(variant, entry); err != nil {
+		return nil, err
+	} else {
+		images := make([]*store.Image, len(page))
+		for i, flake := range page {
+			var image *store.Image
+			if image, err = s.Instance.Image(flake); err != nil {
+				return nil, err
+			} else {
+				images[i] = image
+			}
+		}
+		return images, nil
+	}
+}
diff --git a/api/paths.go b/api/paths.go
index 4caec4a..dc3bc0e 100644
--- a/api/paths.go
+++ b/api/paths.go
@@ -1,33 +1,7 @@
 package api
 
 const (
-	Base           = "/api"
-	SingleUser     = Base + "/single_user"
-	Private        = Base + "/private"
-	Image          = Base + "/image"
-	ImagePage      = Image + "/page"
-	ImagePageField = ImagePage + "/:entry"
-	ImagePageImage = ImagePageField + "/image"
-	ImageField     = Image + "/:flake"
-	ImageFile      = ImageField + "/file"
-	ImagePreview   = ImageField + "/preview"
-	ImageTag       = ImageField + "/tag"
-	ImageTagField  = ImageTag + "/:tag"
-	Tag            = Base + "/tag"
-	TagField       = Tag + "/:tag"
-	TagInfo        = TagField + "/info"
-	TagPage        = TagField + "/page"
-	TagPageField   = TagPage + "/:entry"
-	TagPageImage   = TagPageField + "/image"
-	Search         = Base + "/search"
-	SearchField    = Search + "/:tags"
-	User           = Base + "/user"
-	UserThis       = User + "/this"
-	UserField      = User + "/:flake"
-	UserSecret     = UserField + "/secret"
-	UserImage      = UserField + "/image"
-	UserPassword   = UserField + "/password"
-	Username       = Base + "/username"
-	UsernameField  = Username + "/:name"
-	UsernameAuth   = UsernameField + "/auth"
+	API    = "/api"
+	Base   = API + "/v2"
+	Config = Base + "/config"
 )
diff --git a/api/types.go b/api/types.go
index a86cfb7..c20d613 100644
--- a/api/types.go
+++ b/api/types.go
@@ -1,11 +1,32 @@
 package api
 
+import "random.chars.jp/git/image-board/v2/store"
+
+type BackendInfo struct {
+	Backend     string `json:"backend"`
+	InitialUser string `json:"initial_user"`
+}
+
+type ServerConfig struct {
+	SingleUser bool `json:"single_user"`
+	Private    bool `json:"private"`
+	Register   bool `json:"register"`
+}
+
 type UserPayload struct {
 	Username   string `json:"username"`
 	ID         string `json:"id"`
 	Privileged bool   `json:"privileged"`
 }
 
+func userPayload(user *store.User) *UserPayload {
+	return &UserPayload{
+		Username:   user.Username,
+		ID:         user.Snowflake,
+		Privileged: user.Privileged,
+	}
+}
+
 type UserCreatePayload struct {
 	Username   string `json:"username"`
 	Password   string `json:"password"`
diff --git a/api/v1.go b/api/v1.go
new file mode 100644
index 0000000..cd57f4c
--- /dev/null
+++ b/api/v1.go
@@ -0,0 +1,668 @@
+package api
+
+import (
+	"github.com/gin-gonic/gin"
+	"io"
+	"mime/multipart"
+	"net/http"
+	"random.chars.jp/git/image-board/v2/backend/filesystem"
+	"random.chars.jp/git/image-board/v2/store"
+	"runtime"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+)
+
+const (
+	v1Base           = API + "/v1"
+	v1SingleUser     = v1Base + "/single_user"
+	v1Private        = v1Base + "/private"
+	v1Register       = v1Base + "/register"
+	v1Image          = v1Base + "/image"
+	v1ImagePage      = v1Image + "/page"
+	v1ImagePageField = v1ImagePage + "/:entry"
+	v1ImagePageImage = v1ImagePageField + "/image"
+	v1ImageField     = v1Image + "/:flake"
+	v1ImageFile      = v1ImageField + "/file"
+	v1ImagePreview   = v1ImageField + "/preview"
+	v1ImageTag       = v1ImageField + "/tag"
+	v1ImageTagField  = v1ImageTag + "/:tag"
+	v1Tag            = v1Base + "/tag"
+	v1TagField       = v1Tag + "/:tag"
+	v1TagInfo        = v1TagField + "/info"
+	v1TagPage        = v1TagField + "/page"
+	v1TagPageField   = v1TagPage + "/:entry"
+	v1TagPageImage   = v1TagPageField + "/image"
+	v1Search         = v1Base + "/search"
+	v1SearchField    = v1Search + "/:tags"
+	v1User           = v1Base + "/user"
+	v1UserThis       = v1User + "/this"
+	v1UserField      = v1User + "/:flake"
+	v1UserSecret     = v1UserField + "/secret"
+	v1UserImage      = v1UserField + "/image"
+	v1UserPassword   = v1UserField + "/password"
+	v1Username       = v1Base + "/username"
+	v1UsernameField  = v1Username + "/:name"
+	v1UsernameAuth   = v1UsernameField + "/auth"
+)
+
+func (s *Server) V1() {
+	s.Engine.GET(v1Base, func(context *gin.Context) {
+		context.JSON(http.StatusOK, filesystem.Store{
+			// this is always 1 for backwards compatibility
+			Revision: 1,
+			// determined at runtime
+			Compat: runtime.GOOS == "windows",
+			// proper initial user
+			InitialUser: s.Instance.UserInitial(),
+			// default values
+			PermissionDir:  0700,
+			PermissionFile: 0600,
+		})
+	})
+
+	s.Engine.GET(v1SingleUser, func(context *gin.Context) {
+		context.JSON(http.StatusOK, s.Config.SingleUser)
+	})
+
+	s.Engine.GET(v1Private, func(context *gin.Context) {
+		context.JSON(http.StatusOK, s.Config.Private)
+	})
+
+	s.Engine.GET(v1Register, func(context *gin.Context) {
+		context.JSON(http.StatusOK, s.Config.Register)
+	})
+
+	s.Engine.GET(v1User, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.Users())
+	})
+
+	s.Engine.PUT(v1User, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		privileged := false
+		if user, err := s.getUser(context); err == nil && user.Privileged {
+			privileged = true
+		} else if err != store.ErrNoEntry {
+			doError(context, err)
+			return
+		}
+
+		if !s.Config.Register {
+			if !privileged {
+				context.JSON(http.StatusForbidden, ErrRegistrationDisabled)
+				return
+			}
+		}
+
+		var payload UserCreatePayload
+		if err := context.ShouldBindJSON(&payload); doErrorAPI(context, err) {
+			return
+		}
+
+		if !privileged && payload.Privileged {
+			context.JSON(http.StatusForbidden, ErrPermissionDenied)
+			return
+		}
+
+		s.json(context)(s.Instance.UserAdd(payload.Username, payload.Password, payload.Privileged))
+	})
+
+	s.Engine.GET(v1UserThis, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		context.JSON(http.StatusOK, userPayload(user))
+	})
+
+	s.Engine.GET(v1UserField, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(func() (interface{}, error) {
+			user, err := s.Instance.User(context.Param("flake"))
+			if err == nil {
+				return userPayload(user), nil
+			}
+			return nil, err
+		}())
+	})
+
+	s.Engine.PATCH(v1UserField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		flake := context.Param("flake")
+		if !user.Privileged && (user.Snowflake != flake) {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		var payload UserUpdatePayload
+		if err := context.ShouldBindJSON(&payload); doErrorAPI(context, err) {
+			return
+		}
+
+		if user.Privileged {
+			if err := s.Instance.UserPrivileged(flake, payload.Privileged); doError(context, err) {
+				return
+			}
+		}
+		if err := s.Instance.UserUsernameUpdate(flake, payload.Username); doError(context, err) {
+			return
+		}
+	})
+
+	s.Engine.DELETE(v1UserField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		flake := context.Param("flake")
+		if !user.Privileged && (user.Snowflake != flake) {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		doError(context, s.Instance.UserDestroy(flake))
+	})
+
+	s.Engine.DELETE(v1UserPassword, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		} else if !user.Privileged {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		flake := context.Param("flake")
+		if doError(context, s.Instance.UserPasswordUpdate(flake, "")) {
+			return
+		} else {
+			_, err := s.Instance.UserSecretRegen(flake)
+			doError(context, err)
+		}
+	})
+
+	s.Engine.PUT(v1UserPassword, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		flake := context.Param("flake")
+		if !user.Privileged && (user.Snowflake != flake) {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+		}
+
+		var newPass string
+		if payload, err := context.GetRawData(); doErrorAPI(context, err) {
+			return
+		} else {
+			if !utf8.Valid(payload) {
+				context.JSON(http.StatusBadRequest, Error{Error: "invalid encoding"})
+				return
+			}
+			newPass = string(payload)
+			if len(newPass) > 8192 || strings.Contains(newPass, "\n") {
+				context.JSON(http.StatusBadRequest, Error{Error: "invalid password"})
+				return
+			}
+		}
+
+		if newPass == "" {
+			context.JSON(http.StatusBadRequest, Error{Error: "empty password not allowed"})
+			return
+		}
+
+		if doError(context, s.Instance.UserPasswordUpdate(flake, newPass)) {
+			return
+		}
+
+		s.json(context)(func() (interface{}, error) {
+			secret, err := s.Instance.UserSecretRegen(flake)
+			return UserSecretPayload{Secret: secret}, err
+		}())
+	})
+
+	s.Engine.GET(v1UsernameField, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(func() (interface{}, error) {
+			user, err := s.Instance.UserUsername(context.Param("name"))
+			return userPayload(user), err
+		}())
+	})
+
+	s.Engine.POST(v1UsernameAuth, func(context *gin.Context) {
+		var password string
+
+		if payload, err := context.GetRawData(); doErrorAPI(context, err) {
+			return
+		} else {
+			password = string(payload)
+		}
+
+		username := context.Param("name")
+		if valid, err := s.Instance.UserPasswordValidate(nil, &username, password); doError(context, err) {
+			return
+		} else if valid {
+			s.json(context)(func() (interface{}, error) {
+				var user *store.User
+				user, err = s.Instance.UserUsername(username)
+				if err != nil {
+					return nil, err
+				}
+				return UserSecretPayload{Secret: user.Secret}, nil
+			}())
+		} else {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+		}
+	})
+
+	s.Engine.GET(v1UserSecret, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		flake := context.Param("flake")
+		if !user.Privileged && (user.Snowflake != flake) {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		s.json(context)(func() (interface{}, error) {
+			var err error
+			user, err = s.Instance.User(flake)
+			if err != nil {
+				return nil, err
+			}
+			return UserSecretPayload{Secret: user.Secret}, nil
+		}())
+	})
+
+	s.Engine.PUT(v1UserSecret, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		flake := context.Param("flake")
+		if !user.Privileged && (user.Snowflake != flake) {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		s.json(context)(func() (interface{}, error) {
+			secret, err := s.Instance.UserSecretRegen(flake)
+			return UserSecretPayload{Secret: secret}, err
+		}())
+	})
+
+	s.Engine.GET(v1UserImage, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.UserImages(context.Param("flake")))
+	})
+
+	s.Engine.GET(v1SearchField, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.ImageSearch(strings.Split(context.Param("tags"), "!")))
+	})
+
+	s.Engine.GET(v1Image, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		} else if !user.Privileged {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		s.json(context)(s.Instance.Images())
+	})
+
+	s.Engine.POST(v1Image, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		if payload, err := context.FormFile("image"); doErrorAPI(context, err) {
+			return
+		} else {
+			var file multipart.File
+			if file, err = payload.Open(); doErrorAPI(context, err) {
+				return
+			} else {
+				var data []byte
+				if data, err = io.ReadAll(file); doErrorAPI(context, err) {
+					return
+				} else {
+					s.json(context)(s.Instance.ImageAdd(data, user.Snowflake))
+				}
+			}
+		}
+	})
+
+	s.Engine.GET(v1ImagePage, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.PageTotal(store.ImageRootPageVariant))
+	})
+
+	s.Engine.GET(v1ImagePageField, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		param := context.Param("entry")
+		if entry, err := strconv.Atoi(param); doErrorAPI(context, err) {
+			return
+		} else {
+			s.json(context)(s.Instance.Page(store.ImageRootPageVariant, uint64(entry)))
+		}
+	})
+
+	s.Engine.GET(v1ImagePageImage, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		param := context.Param("entry")
+		if entry, err := strconv.Atoi(param); doErrorAPI(context, err) {
+			return
+		} else {
+			s.json(context)(s.pageImages(store.ImageRootPageVariant, uint64(entry)))
+		}
+	})
+
+	s.Engine.GET(v1ImageField, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.Image(context.Param("flake")))
+	})
+
+	s.Engine.PATCH(v1ImageField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		var image *store.Image
+		if i, err := s.Instance.Image(context.Param("flake")); doError(context, err) {
+			return
+		} else {
+			image = i
+		}
+
+		if !user.Privileged && (image.User != user.Snowflake) {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		var payload ImageUpdatePayload
+		if err := context.ShouldBindJSON(&payload); doErrorAPI(context, err) {
+			return
+		}
+
+		doError(context,
+			s.Instance.ImageUpdate(image.Snowflake,
+				payload.Source, payload.Parent, payload.Commentary, payload.CommentaryTranslation))
+	})
+
+	s.Engine.DELETE(v1ImageField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		var image *store.Image
+		if i, err := s.Instance.Image(context.Param("flake")); doError(context, err) {
+			return
+		} else {
+			image = i
+		}
+
+		if !user.Privileged && (user.Snowflake != image.User) {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+		doError(context, s.Instance.ImageDestroy(image.Snowflake))
+	})
+
+	s.Engine.GET(v1ImageFile, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		var (
+			image *store.Image
+			data  []byte
+		)
+		if h, err := s.Instance.ImageSnowflakeHash(context.Param("flake")); doError(context, err) {
+			return
+		} else {
+			if image, data, err = s.Instance.ImageData(h, false); err != nil {
+				if err == store.ErrNoEntry {
+					// simulate behaviour of old api
+					context.JSON(http.StatusNotFound, Error{Error: "not found"})
+					return
+				}
+				doError(context, err)
+				return
+			}
+		}
+
+		context.Data(http.StatusOK, "image/"+image.Type, data)
+	})
+
+	s.Engine.GET(v1ImagePreview, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		var data []byte
+		if h, err := s.Instance.ImageSnowflakeHash(context.Param("flake")); doError(context, err) {
+			return
+		} else {
+			if _, data, err = s.Instance.ImageData(h, true); err != nil {
+				if err == store.ErrNoEntry {
+					// simulate behaviour of old api
+					context.JSON(http.StatusNotFound, Error{Error: "not found"})
+					return
+				}
+				doError(context, err)
+				return
+			}
+		}
+
+		context.Data(http.StatusOK, "image/jpeg", data)
+	})
+
+	s.Engine.GET(v1ImageTag, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.ImageTags(context.Param("flake")))
+	})
+
+	s.Engine.PUT(v1ImageTagField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		flake := context.Param("flake")
+		if valid, err := s.Instance.UserImage(user.Snowflake, flake); doError(context, err) {
+			return
+		} else if !valid {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		doError(context, s.Instance.ImageTagAdd(flake, context.Param("tag")))
+	})
+
+	s.Engine.DELETE(v1ImageTagField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		}
+
+		flake := context.Param("flake")
+		if valid, err := s.Instance.UserImage(user.Snowflake, flake); doError(context, err) {
+			return
+		} else if !valid {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		doError(context, s.Instance.ImageTagRemove(flake, context.Param("tag")))
+	})
+
+	s.Engine.GET(v1Tag, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.Tags())
+	})
+
+	s.Engine.GET(v1TagField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		} else if !user.Privileged {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		s.json(context)(s.Instance.TagImages(context.Param("tag")))
+	})
+
+	s.Engine.PUT(v1TagField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		} else if !user.Privileged {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		doError(context, s.Instance.TagAdd(context.Param("tag")))
+	})
+
+	s.Engine.DELETE(v1TagField, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		} else if !user.Privileged {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		doError(context, s.Instance.TagDestroy(context.Param("tag")))
+	})
+
+	s.Engine.GET(v1TagPage, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		tag := context.Param("tag")
+		if !store.MatchName(tag) {
+			context.JSON(http.StatusBadRequest, Error{Error: store.ErrInvalidInput.Error()})
+			return
+		}
+		s.json(context)(s.Instance.PageTotal("tag_" + tag))
+	})
+
+	s.Engine.GET(v1TagPageField, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		tag := context.Param("tag")
+		if !store.MatchName(tag) {
+			context.JSON(http.StatusBadRequest, Error{Error: store.ErrInvalidInput.Error()})
+			return
+		}
+
+		param := context.Param("entry")
+		if entry, err := strconv.Atoi(param); doErrorAPI(context, err) {
+			return
+		} else {
+			s.json(context)(s.Instance.Page("tag_"+tag, uint64(entry)))
+		}
+	})
+
+	s.Engine.GET(v1TagPageImage, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		tag := context.Param("tag")
+		if !store.MatchName(tag) {
+			context.JSON(http.StatusBadRequest, Error{Error: store.ErrInvalidInput.Error()})
+			return
+		}
+
+		param := context.Param("entry")
+		if entry, err := strconv.Atoi(param); doErrorAPI(context, err) {
+			return
+		} else {
+			s.json(context)(s.pageImages("tag_"+tag, uint64(entry)))
+		}
+	})
+
+	s.Engine.GET(v1TagInfo, func(context *gin.Context) {
+		if !s.privateAccessible(context) {
+			return
+		}
+
+		s.json(context)(s.Instance.Tag(context.Param("tag")))
+	})
+
+	s.Engine.PATCH(v1TagInfo, func(context *gin.Context) {
+		user := s.mustGetUser(context)
+		if user == nil {
+			return
+		} else if !user.Privileged {
+			context.JSON(http.StatusForbidden, ErrUnauthorized)
+			return
+		}
+
+		var payload TagUpdatePayload
+		if err := context.ShouldBindJSON(&payload); doErrorAPI(context, err) {
+			return
+		}
+		doError(context, s.Instance.TagType(context.Param("tag"), payload.Type))
+	})
+}
diff --git a/api/v2.go b/api/v2.go
new file mode 100644
index 0000000..8938ad3
--- /dev/null
+++ b/api/v2.go
@@ -0,0 +1,21 @@
+package api
+
+import (
+	"github.com/gin-gonic/gin"
+	"net/http"
+)
+
+func (s *Server) V2() {
+	backend := BackendInfo{
+		Backend:     s.Instance.Name(),
+		InitialUser: s.Instance.UserInitial(),
+	}
+
+	s.Engine.GET(Base, func(context *gin.Context) {
+		context.JSON(http.StatusOK, backend)
+	})
+
+	s.Engine.GET(Config, func(context *gin.Context) {
+		context.JSON(http.StatusOK, s.Config)
+	})
+}
diff --git a/backend/filesystem/image.go b/backend/filesystem/image.go
new file mode 100644
index 0000000..3f2b2e4
--- /dev/null
+++ b/backend/filesystem/image.go
@@ -0,0 +1,553 @@
+package filesystem
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"encoding/json"
+	"fmt"
+	"image"
+	_ "image/gif"
+	"image/jpeg"
+	_ "image/png"
+	"log"
+	"os"
+	"random.chars.jp/git/image-board/v2/store"
+)
+
+// ImageHashes returns a slice of image hashes.
+func (s *Store) ImageHashes() ([]string, error) {
+	var images []string
+	if entries, err := os.ReadDir(s.ImagesHashDir()); err != nil {
+		return nil, err
+	} else {
+		for _, entry := range entries {
+			if entry.IsDir() {
+				var subEntries []os.DirEntry
+				if subEntries, err = os.ReadDir(s.ImagesHashDir() + "/" + entry.Name()); err != nil {
+					return nil, err
+				} else {
+					for _, subEntry := range subEntries {
+						images = append(images, entry.Name()+subEntry.Name())
+					}
+				}
+			}
+		}
+	}
+	return images, nil
+}
+
+// ImageHash returns an image with specific hash.
+func (s *Store) ImageHash(hash string) (*store.Image, error) {
+	if !store.MatchSha256(hash) {
+		return nil, store.ErrInvalidInput
+	} else if !s.file(s.ImageMetadataPath(hash)) {
+		return nil, store.ErrNoEntry
+	}
+
+	s.getLock(hash).RLock()
+	defer s.getLock(hash).RUnlock()
+
+	return s.imageMetadataRead(s.ImageMetadataPath(hash))
+}
+
+// imageMetadataRead reads an image metadata file.
+func (s *Store) imageMetadataRead(path string) (*store.Image, error) {
+	var metadata store.Image
+	if payload, err := os.ReadFile(path); err != nil {
+		if os.IsNotExist(err) {
+			return nil, store.ErrNoEntry
+		}
+		return nil, err
+	} else {
+		if err = json.Unmarshal(payload, &metadata); err != nil {
+			return nil, err
+		}
+	}
+	return &metadata, nil
+}
+
+// ImageData returns an image and its data with a specific hash.
+func (s *Store) ImageData(hash string, preview bool) (*store.Image, []byte, error) {
+	if !store.MatchSha256(hash) {
+		return nil, nil, store.ErrInvalidInput
+	} else if !s.file(s.ImageMetadataPath(hash)) {
+		return nil, nil, store.ErrNoEntry
+	}
+
+	s.getLock(hash).RLock()
+	defer s.getLock(hash).RUnlock()
+
+	var metadata *store.Image
+	if m, err := s.imageMetadataRead(s.ImageMetadataPath(hash)); err != nil {
+		return nil, nil, err
+	} else {
+		metadata = m
+	}
+
+	var path string
+	if !preview {
+		path = s.ImageFilePath(hash)
+	} else {
+		path = s.ImagePreviewFilePath(hash)
+	}
+	if data, err := os.ReadFile(path); err != nil {
+		return nil, nil, err
+	} else {
+		return metadata, data, nil
+	}
+}
+
+// ImageTags returns tags of an image with specific flake.
+func (s *Store) ImageTags(flake string) ([]string, error) {
+	if !store.Numerical(flake) {
+		return nil, store.ErrInvalidInput
+	} else if !s.dir(s.ImageTagsPath(flake)) {
+		return nil, store.ErrNoEntry
+	}
+
+	// Lock flake for directory-based operations
+	s.getLock(flake).RLock()
+	defer s.getLock(flake).RUnlock()
+
+	return s.imageTags(flake)
+}
+
+func (s *Store) imageTags(flake string) ([]string, error) {
+	var tags []string
+	if entries, err := os.ReadDir(s.ImageTagsPath(flake)); err != nil {
+		return nil, err
+	} else {
+		for _, entry := range entries {
+			tags = append(tags, entry.Name())
+		}
+	}
+	return tags, nil
+}
+
+// ImageHasTag figures out if an image has a tag.
+func (s *Store) ImageHasTag(flake, tag string) (bool, error) {
+	if !store.Numerical(flake) {
+		return false, store.ErrInvalidInput
+	} else if !store.MatchName(tag) {
+		return false, store.ErrNoEntry
+	}
+	return s.file(s.ImageTagsPath(flake) + "/" + tag), nil
+}
+
+//ImageSearch searches for images with specific tags.
+func (s *Store) ImageSearch(tags []string) ([]string, error) {
+	if len(tags) < 1 || tags == nil {
+		return nil, store.ErrInvalidInput
+	}
+
+	// Check if every tag matches name regex and exists
+	for _, tag := range tags {
+		if !store.MatchName(tag) {
+			return nil, store.ErrInvalidInput
+		} else if !s.file(s.TagPath(tag)) {
+			return nil, store.ErrNoEntry
+		}
+	}
+
+	// Return if there's only one tag to search for
+	if len(tags) == 1 {
+		return s.TagImages(tags[0])
+	}
+
+	// Find entry with the least pages
+	entry := struct {
+		min   uint64
+		index int
+	}{}
+
+	entry.index = 0
+	if pt, err := s.PageTotal("tag_" + tags[0]); err != nil {
+		return nil, err
+	} else {
+		entry.min = pt
+	}
+
+	for i := 1; i < len(tags); i++ {
+		if entry.min <= 1 {
+			break
+		}
+
+		if pages, err := s.PageTotal("tag_" + tags[i]); err != nil {
+			return nil, err
+		} else if pages < entry.min {
+			entry.min = pages
+			entry.index = i
+		}
+	}
+
+	// Get initial tag
+	var initial []string
+	if init, err := s.TagImages(tags[entry.index]); err != nil {
+		return nil, err
+	} else {
+		initial = init
+	}
+
+	// Result slice
+	var result []string
+
+	// Walk flakes from initial tag
+	for _, flake := range initial {
+		match := true
+		// Walk all remaining tags
+		for i, tag := range tags {
+			// Skip the entrypoint entry
+			if i == entry.index {
+				continue
+			}
+
+			// Check if match
+			if b, err := s.ImageHasTag(flake, tag); err != nil {
+				return nil, err
+			} else if !b {
+				match = false
+				break
+			}
+		}
+
+		// Append flake if all tags matched
+		if match {
+			result = append(result, flake)
+		}
+	}
+
+	return result, nil
+}
+
+// ImageAdd adds an image to the store.
+func (s *Store) ImageAdd(data []byte, flake string) (*store.Image, error) {
+	if !store.Numerical(flake) {
+		return nil, store.ErrInvalidInput
+	} else if !s.dir(s.UserPath(flake)) {
+		return nil, store.ErrNoEntry
+	}
+
+	info := store.Image{Snowflake: store.MakeFlake(store.ImageNode).String(), User: flake}
+	info.Hash = fmt.Sprintf("%x", sha256.Sum256(data))
+
+	if s.file(s.ImagePath(info.Hash)) {
+		return nil, store.ErrAlreadyExists
+	}
+
+	s.getLock(info.Hash).Lock()
+	defer s.getLock(info.Hash).Unlock()
+
+	var prev image.Image
+	if i, format, err := image.Decode(bytes.NewReader(data)); err != nil {
+		return nil, err
+	} else {
+		prev = store.MakePreview(i)
+		info.Type = format
+	}
+
+	if err := os.MkdirAll(s.ImageHashTagsPath(info.Hash), s.PermissionDir); err != nil {
+		return nil, err
+	}
+
+	if payload, err := json.Marshal(info); err != nil {
+		return nil, err
+	} else if err = os.WriteFile(s.ImageMetadataPath(info.Hash), payload, s.PermissionFile); err != nil {
+		return nil, err
+	}
+
+	if err := os.WriteFile(s.ImageFilePath(info.Hash), data, s.PermissionFile); err != nil {
+		return nil, err
+	}
+
+	if preview, err := os.Create(s.ImagePreviewFilePath(info.Hash)); err != nil {
+		return nil, err
+	} else if err = jpeg.Encode(preview, prev, &jpeg.Options{Quality: 100}); err != nil {
+		return nil, err
+	} else if err = preview.Close(); err != nil {
+		return nil, err
+	}
+
+	if err := s.link("../images/"+s.ImageHashSplit(info.Hash), s.ImageSnowflakePath(info.Snowflake)); err != nil {
+		return nil, err
+	}
+	if err := s.link("../../../images/"+s.ImageHashSplit(info.Hash), s.UserImagesPath(flake)+"/"+info.Snowflake); err != nil {
+		return nil, err
+	}
+
+	if err := s.pageInsert(store.ImageRootPageVariant, info.Snowflake); err != nil {
+		return nil, err
+	}
+
+	log.Printf("image hash %s snowflake %s type %s added by user %s", info.Hash, info.Snowflake, info.Type, info.User)
+	return &info, nil
+}
+
+// ImageUpdate updates image metadata.
+func (s *Store) ImageUpdate(flake, source, parent, commentary, commentaryTranslation string) error {
+	if len(source) >= 1024 ||
+		len(commentary) >= 65536 || len(commentaryTranslation) >= 65536 {
+		return store.ErrInvalidInput
+	}
+
+	var info *store.Image
+	if i, err := s.Image(flake); err != nil {
+		return err
+	} else {
+		info = i
+	}
+
+	s.getLock(info.Hash).Lock()
+	defer s.getLock(info.Hash).Unlock()
+
+	var msg string
+
+	if source != "\000" && store.MatchURL(source) {
+		info.Source = source
+		msg += "source"
+	}
+
+	if parent != "\000" && parent != info.Snowflake && parent != info.Parent {
+		var p *store.Image
+		if parent == "" {
+			if par, err := s.Image(info.Parent); err != nil {
+				return err
+			} else {
+				p = par
+			}
+			p.Child = ""
+		} else {
+			if par, err := s.Image(parent); err != nil {
+				return err
+			} else {
+				p = par
+			}
+			if p.Child != "" {
+				goto end
+			}
+			p.Child = info.Snowflake
+		}
+
+		info.Parent = parent
+
+		s.getLock(p.Hash).Lock()
+		if err := s.imageMetadataWrite(p); err != nil {
+			return err
+		}
+		s.getLock(p.Hash).Unlock()
+
+		if msg != "" {
+			msg += ", "
+		}
+		msg += "parent " + parent
+	end:
+	}
+	if commentary != "\000" {
+		info.Commentary = commentary
+
+		if msg != "" {
+			msg += ", "
+		}
+		msg += "commentary"
+	}
+	if commentaryTranslation != "\000" {
+		info.CommentaryTranslation = commentaryTranslation
+
+		if msg != "" {
+			msg += ", "
+		}
+		msg += "commentary translation"
+	}
+
+	if msg != "" {
+		if err := s.imageMetadataWrite(info); err != nil {
+			return err
+		} else {
+			log.Printf("image %s %s updated", info.Snowflake, msg)
+			return nil
+		}
+	}
+
+	return nil
+}
+
+func (s *Store) imageMetadataWrite(info *store.Image) error {
+	if payload, err := json.Marshal(info); err != nil {
+		return err
+	} else {
+		return os.WriteFile(s.ImageMetadataPath(info.Hash), payload, s.PermissionFile)
+	}
+}
+
+// Images returns a slice of image snowflakes.
+func (s *Store) Images() ([]string, error) {
+	var snowflakes []string
+	if entries, err := os.ReadDir(s.ImagesSnowflakeDir()); err != nil {
+		return nil, err
+	} else {
+		for _, entry := range entries {
+			snowflakes = append(snowflakes, entry.Name())
+		}
+	}
+	return snowflakes, nil
+}
+
+// ImageSnowflakeHash returns image hash from snowflake.
+func (s *Store) ImageSnowflakeHash(flake string) (string, error) {
+	if !store.Numerical(flake) {
+		return "", store.ErrInvalidInput
+	}
+
+	if !s.Compat {
+		img, err := s.imageMetadataRead(s.ImageSnowflakePath(flake) + "/" + infoJson)
+		return img.Hash, err
+	} else {
+		if path, err := os.ReadFile(s.ImageSnowflakePath(flake)); err != nil {
+			if os.IsNotExist(err) {
+				return "", store.ErrNoEntry
+			}
+			return "", err
+		} else {
+			var img *store.Image
+			img, err = s.imageMetadataRead(string(path) + "/" + infoJson)
+			return img.Hash, err
+		}
+	}
+}
+
+// Image returns image that has specific snowflake.
+func (s *Store) Image(flake string) (*store.Image, error) {
+	if hash, err := s.ImageSnowflakeHash(flake); err != nil {
+		return nil, err
+	} else {
+		return s.ImageHash(hash)
+	}
+}
+
+// ImageDestroy destroys an image.
+func (s *Store) ImageDestroy(flake string) error {
+	if !store.Numerical(flake) {
+		return store.ErrInvalidInput
+	} else if !s.dir(s.ImageSnowflakePath(flake)) {
+		return store.ErrNoEntry
+	}
+
+	var hash string
+
+	if h, err := s.ImageSnowflakeHash(flake); err != nil {
+		return err
+	} else {
+		hash = h
+	}
+
+	// Attempt to disassociate parent
+	if err := s.ImageUpdate(flake, "\000", "", "\000", "\000"); err != nil {
+		return err
+	}
+
+	s.getLock(hash).Lock()
+	defer s.getLock(hash).Unlock()
+
+	var info *store.Image
+
+	if i, err := s.imageMetadataRead(s.ImageMetadataPath(hash)); err != nil {
+		return err
+	} else {
+		info = i
+	}
+
+	// Disassociate child if set
+	if info.Child != "" {
+		if err := s.ImageUpdate(info.Child, "\000", "", "\000", "\000"); err != nil {
+			return err
+		}
+	}
+
+	// Untag the image completely
+	if tags, err := s.imageTags(info.Snowflake); err != nil {
+		return err
+	} else {
+		for _, tag := range tags {
+			if err = s.imageTagRemove(info.Snowflake, tag); err != nil {
+				return err
+			}
+		}
+	}
+
+	if err := os.Remove(s.ImageSnowflakePath(info.Snowflake)); err != nil {
+		return err
+	}
+
+	if err := os.Remove(s.UserImagesPath(info.User) + "/" + info.Snowflake); err != nil {
+		return err
+	}
+
+	if err := os.RemoveAll(s.ImagePath(hash)); err != nil {
+		return err
+	}
+
+	if err := s.pageRegisterRemove(store.ImageRootPageVariant, info.Snowflake); err != nil {
+		return err
+	}
+
+	log.Printf("image hash %s snowflake %s destroyed", info.Hash, info.Snowflake)
+	return nil
+}
+
+// ImageTagAdd adds a tag to an image with specific snowflake.
+func (s *Store) ImageTagAdd(flake, tag string) error {
+	if !store.MatchName(tag) || !store.Numerical(flake) {
+		return store.ErrInvalidInput
+	} else if !s.dir(s.ImageTagsPath(flake)) || !s.dir(s.TagPath(tag)) || s.file(s.TagPath(tag)+"/"+flake) {
+		return store.ErrNoEntry
+	}
+
+	s.getLock(flake).Lock()
+	defer s.getLock(flake).Unlock()
+
+	if err := s.link("../../snowflakes/"+flake, s.TagPath(tag)+"/"+flake); err != nil {
+		return err
+	}
+	if err := s.link("../../../../tags/"+tag, s.ImageSnowflakePath(flake)+"/tags/"+tag); err != nil {
+		return err
+	}
+	if err := s.pageInsert("tag_"+tag, flake); err != nil {
+		return err
+	}
+
+	log.Printf("image snowflake %s tagged with %s", flake, tag)
+	return nil
+}
+
+// ImageTagRemove removes a tag from an image with specific snowflake.
+func (s *Store) ImageTagRemove(flake, tag string) error {
+	if !store.MatchName(tag) || !store.Numerical(flake) {
+		return store.ErrInvalidInput
+	} else if !s.dir(s.ImageTagsPath(flake)) || !s.dir(s.TagPath(tag)) {
+		return store.ErrNoEntry
+	}
+
+	s.getLock(flake).Lock()
+	defer s.getLock(flake).Unlock()
+
+	return s.imageTagRemove(flake, tag)
+}
+
+func (s *Store) imageTagRemove(flake, tag string) error {
+	if s.file(s.ImageTagsPath(flake) + "/" + tag) {
+		if err := os.Remove(s.ImageTagsPath(flake) + "/" + tag); err != nil {
+			return err
+		}
+	}
+	if s.file(s.TagPath(tag) + "/" + flake) {
+		if err := os.Remove(s.TagPath(tag) + "/" + flake); err != nil {
+			return err
+		}
+	}
+
+	if err := s.pageRegisterRemove("tag_"+tag, flake); err != nil {
+		return err
+	} else {
+		log.Printf("image snowflake %s untagged %s", flake, tag)
+		return nil
+	}
+}
diff --git a/backend/filesystem/page.go b/backend/filesystem/page.go
new file mode 100644
index 0000000..d9bfc95
--- /dev/null
+++ b/backend/filesystem/page.go
@@ -0,0 +1,213 @@
+package filesystem
+
+import (
+	"encoding/binary"
+	"github.com/syndtr/goleveldb/leveldb"
+	"log"
+	"os"
+	"random.chars.jp/git/image-board/v2/store"
+)
+
+// pageDB returns leveldb of page variant and creates it as required.
+func (s *Store) pageDB(variant string) (*leveldb.DB, error) {
+	mutex := s.getLock("pageDB_get")
+	mutex.Lock()
+	defer mutex.Unlock()
+
+	if ldb, ok := s.pageldb[variant]; ok && ldb != nil {
+		return ldb, nil
+	} else {
+		if db, err := leveldb.OpenFile(s.PageVariantPath(variant), nil); err != nil {
+			return nil, err
+		} else {
+			s.pageldb[variant] = db
+			if _, err = db.Get([]byte("\000"), nil); err != nil {
+				log.Printf("Page variant %s created.", variant)
+				if err = s.pageSetTotalCountNoDestroy(0, db); err != nil {
+					return nil, err
+				}
+			}
+			return db, nil
+		}
+	}
+}
+
+// pageDBDestroy destroys leveldb of page variant.
+func (s *Store) pageDBDestroy(variant string) error {
+	var db *leveldb.DB
+	if d, err := s.pageDB(variant); err != nil {
+		return err
+	} else {
+		db = d
+	}
+	if err := db.Close(); err != nil {
+		return err
+	} else {
+		delete(s.pageldb, variant)
+	}
+
+	if err := os.RemoveAll(s.PageVariantPath(variant)); err != nil {
+		return err
+	} else {
+		log.Printf("page variant %s destroyed", variant)
+		return nil
+	}
+}
+
+// pageGetTotalCount gets total count of a page variant.
+func (s *Store) pageGetTotalCount(variant string) (uint64, error) {
+	var db *leveldb.DB
+	if d, err := s.pageDB(variant); err != nil {
+		return 0, err
+	} else {
+		db = d
+	}
+
+	if payload, err := db.Get([]byte("\000"), nil); err != nil {
+		return 0, err
+	} else {
+		return binary.LittleEndian.Uint64(payload), nil
+	}
+}
+
+// pageSetTotalCountNoDestroy sets total count of a page variant.
+func (s *Store) pageSetTotalCountNoDestroy(value uint64, db *leveldb.DB) error {
+	payload := make([]byte, 8)
+	binary.LittleEndian.PutUint64(payload, value)
+	return db.Put([]byte("\000"), payload, nil)
+}
+
+// pageSetTotalCount sets total count of a page variant and destroys it if zero.
+func (s *Store) pageSetTotalCount(variant string, value uint64) error {
+	if value == 0 {
+		return s.pageDBDestroy(variant)
+	}
+
+	var db *leveldb.DB
+	if d, err := s.pageDB(variant); err != nil {
+		return err
+	} else {
+		db = d
+	}
+	return s.pageSetTotalCountNoDestroy(value, db)
+}
+
+// pageAdvanceTotalCount advances total count of a page variant.
+func (s *Store) pageAdvanceTotalCount(variant string) error {
+	if t, err := s.pageGetTotalCount(variant); err != nil {
+		return err
+	} else {
+		return s.pageSetTotalCount(variant, t+1)
+	}
+}
+
+// pageReduceTotalCount reduces total count of a page variant.
+func (s *Store) pageReduceTotalCount(variant string) error {
+	if total, err := s.pageGetTotalCount(variant); err != nil {
+		return err
+	} else if total == 0 {
+		return nil
+	} else {
+		return s.pageSetTotalCount(variant, total-1)
+	}
+}
+
+// PageTotal returns total amount of pages.
+func (s *Store) PageTotal(variant string) (uint64, error) {
+	if t, err := s.pageGetTotalCount(variant); err != nil {
+		return 0, err
+	} else {
+		if t == 0 {
+			return t, nil
+		} else {
+			return (t / store.PageSize) + 1, nil
+		}
+	}
+}
+
+// Page returns all entries in a page.
+func (s *Store) Page(variant string, entry uint64) ([]string, error) {
+	if pt, err := s.PageTotal(variant); err != nil {
+		return nil, err
+	} else {
+		if entry >= pt {
+			return nil, store.ErrNoEntry
+		}
+	}
+
+	var page []string
+	start := entry * store.PageSize
+	end := start + store.PageSize
+	begin := false
+
+	var db *leveldb.DB
+	if d, err := s.pageDB(variant); err != nil {
+		return nil, err
+	} else {
+		db = d
+	}
+
+	iter := db.NewIterator(nil, nil)
+	var i uint64 = 0
+	for iter.Next() {
+		if i == end {
+			break
+		}
+		if begin {
+			page = append(page, string(iter.Key()))
+		} else {
+			if i >= start {
+				begin = true
+			}
+		}
+		i++
+	}
+	iter.Release()
+	if err := iter.Error(); err != nil {
+		return nil, err
+	}
+
+	return page, nil
+}
+
+// PageInsert inserts an image into the index.
+func (s *Store) pageInsert(variant, flake string) error {
+	if !store.Numerical(flake) {
+		return store.ErrInvalidInput
+	} else if !s.dir(s.ImageSnowflakePath(flake)) {
+		return store.ErrNoEntry
+	}
+
+	s.getLock("page_" + variant).Lock()
+	defer s.getLock("page_" + variant).Unlock()
+
+	var db *leveldb.DB
+	if d, err := s.pageDB(variant); err != nil {
+		return err
+	} else {
+		db = d
+	}
+
+	if err := db.Put([]byte(flake), []byte{}, nil); err != nil {
+		return err
+	}
+	return s.pageAdvanceTotalCount(variant)
+}
+
+// PageRegisterRemove registers an image remove.
+func (s *Store) pageRegisterRemove(variant, flake string) error {
+	s.getLock("page_" + variant).Lock()
+	defer s.getLock("page_" + variant).Unlock()
+
+	var db *leveldb.DB
+	if d, err := s.pageDB(variant); err != nil {
+		return err
+	} else {
+		db = d
+	}
+
+	if err := db.Delete([]byte(flake), nil); err != nil {
+		return err
+	}
+	return s.pageReduceTotalCount(variant)
+}
diff --git a/store/paths.go b/backend/filesystem/paths.go
similarity index 73%
rename from store/paths.go
rename to backend/filesystem/paths.go
index b31f3d8..7189b18 100644
--- a/store/paths.go
+++ b/backend/filesystem/paths.go
@@ -1,15 +1,15 @@
-package store
+package filesystem
 
 const infoJson = "info.json"
 
 // LockPath returns path to lock file.
 func (s *Store) LockPath() string {
-	return s.Path + "/lock"
+	return s.path + "/lock"
 }
 
 // TagsDir returns path to tags.
 func (s *Store) TagsDir() string {
-	return s.Path + "/tags"
+	return s.path + "/tags"
 }
 
 // TagPath returns path to a specific tag.
@@ -22,14 +22,19 @@ func (s *Store) TagMetadataPath(tag string) string {
 	return s.TagPath(tag) + "/info.json"
 }
 
-// ImagesDir returns path to images.
-func (s *Store) ImagesDir() string {
-	return s.Path + "/images"
+// ImagesBaseDir returns path to images.
+func (s *Store) ImagesBaseDir() string {
+	return s.path + "/images"
+}
+
+// ImagesHashDir returns path to image hashes.
+func (s *Store) ImagesHashDir() string {
+	return s.ImagesBaseDir() + "/hashes"
 }
 
 // ImagePath returns path to an image with specific hash.
 func (s *Store) ImagePath(hash string) string {
-	return s.ImagesDir() + "/" + s.ImageHashSplit(hash)
+	return s.ImagesHashDir() + "/" + s.ImageHashSplit(hash)
 }
 
 // ImageHashSplit returns split image hash.
@@ -64,7 +69,7 @@ func (s *Store) ImageHashTagsPath(hash string) string {
 
 // ImagesSnowflakeDir returns path to image snowflakes.
 func (s *Store) ImagesSnowflakeDir() string {
-	return s.Path + "/snowflakes"
+	return s.ImagesBaseDir() + "/snowflakes"
 }
 
 // ImageSnowflakePath returns path to an image with specific snowflake.
@@ -72,9 +77,29 @@ func (s *Store) ImageSnowflakePath(flake string) string {
 	return s.ImagesSnowflakeDir() + "/" + flake
 }
 
+// TombstoneDir returns path to tombstones.
+func (s *Store) TombstoneDir() string {
+	return s.UsersBaseDir() + "/tombstones"
+}
+
+// TombstonePath returns path to a tombstone with specific snowflake.
+func (s *Store) TombstonePath(flake string) string {
+	return s.TombstoneDir() + "/" + flake
+}
+
+// TombstoneMetadataPath returns path to a tombstone's metadata with specific snowflake.
+func (s *Store) TombstoneMetadataPath(flake string) string {
+	return s.TombstonePath(flake) + "/tombstone"
+}
+
+// UsersBaseDir returns path to users.
+func (s *Store) UsersBaseDir() string {
+	return s.path + "/users"
+}
+
 // UsersDir returns path to users.
 func (s *Store) UsersDir() string {
-	return s.Path + "/users"
+	return s.UsersBaseDir() + "/snowflakes"
 }
 
 // UserPath returns path to a user with specific snowflake.
@@ -99,7 +124,7 @@ func (s *Store) UserPasswordPath(flake string) string {
 
 // UsernamesDir returns path to usernames.
 func (s *Store) UsernamesDir() string {
-	return s.Path + "/usernames"
+	return s.UsersBaseDir() + "/usernames"
 }
 
 // UsernamePath returns path to username.
@@ -109,7 +134,7 @@ func (s *Store) UsernamePath(name string) string {
 
 // SecretsDir returns path to tokens.
 func (s *Store) SecretsDir() string {
-	return s.Path + "/secrets"
+	return s.UsersBaseDir() + "/secrets"
 }
 
 // SecretPath returns path to tokens.
@@ -119,7 +144,7 @@ func (s *Store) SecretPath(secret string) string {
 
 // PageBaseDir returns path to page base directory.
 func (s *Store) PageBaseDir() string {
-	return s.Path + "/pages"
+	return s.path + "/pages"
 }
 
 // PageVariantPath returns path to pages of a variant.
diff --git a/backend/filesystem/secret.go b/backend/filesystem/secret.go
new file mode 100644
index 0000000..1fc3600
--- /dev/null
+++ b/backend/filesystem/secret.go
@@ -0,0 +1,47 @@
+package filesystem
+
+import (
+	"os"
+	"random.chars.jp/git/image-board/v2/store"
+)
+
+// SecretLookup looks up a user from a secret.
+func (s *Store) SecretLookup(secret string) (*store.User, error) {
+	if !store.MatchSecret(secret) {
+		return nil, store.ErrInvalidInput
+	} else if !s.file(s.SecretPath(secret)) {
+		return nil, store.ErrNoEntry
+	}
+	if !s.Compat {
+		return s.user(s.SecretPath(secret) + "/" + infoJson)
+	} else {
+		if path, err := os.ReadFile(s.SecretPath(secret)); err != nil {
+			return nil, err
+		} else {
+			return s.user(string(path) + "/" + infoJson)
+		}
+	}
+}
+
+// vvvvvvvvvvvvvv Don't take this comment seriously!
+
+// SecretValidate validates the validity of a probably-valid secret with valid format.
+func (s *Store) SecretValidate(secret string) bool {
+	return store.MatchSecret(secret) && s.file(s.SecretPath(secret))
+}
+
+// secretAssociate associates a secret with a user.
+func (s *Store) secretAssociate(secret, flake string) error {
+	if s.file(s.SecretPath(secret)) {
+		return store.ErrAlreadyExists
+	}
+	return s.link("../users/"+flake, s.SecretPath(secret))
+}
+
+// secretDisassociate disassociates a secret.
+func (s *Store) secretDisassociate(secret string) error {
+	if !s.file(s.SecretPath(secret)) {
+		return store.ErrNoEntry
+	}
+	return os.Remove(s.SecretPath(secret))
+}
diff --git a/backend/filesystem/store.go b/backend/filesystem/store.go
new file mode 100644
index 0000000..3dcb9ee
--- /dev/null
+++ b/backend/filesystem/store.go
@@ -0,0 +1,266 @@
+package filesystem
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/syndtr/goleveldb/leveldb"
+	"log"
+	"os"
+	"random.chars.jp/git/image-board/v2/store"
+	"runtime"
+	"strconv"
+	"sync"
+)
+
+const revision = 2
+
+// Store represents a file store.
+type Store struct {
+	Revision       int         `json:"revision"`
+	Compat         bool        `json:"compat"`
+	InitialUser    string      `json:"initial_user"`
+	PermissionDir  os.FileMode `json:"permission_dir"`
+	PermissionFile os.FileMode `json:"permission_file"`
+
+	verbose bool
+	path    string
+	pageldb map[string]*leveldb.DB
+	mutex   map[string]*sync.RWMutex
+	giant   sync.RWMutex
+}
+
+// New returns a pointer to a new instance of Store.
+func New(path string, verbose bool) *Store {
+	return &Store{
+		verbose: verbose,
+		path:    path,
+		pageldb: make(map[string]*leveldb.DB),
+		mutex:   make(map[string]*sync.RWMutex),
+		giant:   sync.RWMutex{},
+	}
+}
+
+// Open opens the Store.
+func (s *Store) Open() error {
+	if stat, err := os.Stat(s.path); err != nil {
+		if !os.IsNotExist(err) {
+			return err
+		}
+
+		log.Printf("initializing new store %s", s.path)
+
+		s.Revision = revision
+		s.Compat = runtime.GOOS == "windows"
+		s.PermissionDir = 0700
+		s.PermissionFile = 0600
+
+		if err = s.create(); err != nil {
+			return err
+		}
+	} else {
+		if !stat.IsDir() {
+			return store.ErrNotDirectory
+		}
+
+		// Load and parse store info.
+		var payload []byte
+		if payload, err = os.ReadFile(s.path + "/" + infoJson); err != nil {
+			return err
+		} else {
+			if err = json.Unmarshal(payload, &s); err != nil {
+				return err
+			}
+		}
+
+		if s.Revision != revision {
+			if err = s.upgrade(); err != nil {
+				return err
+			}
+		}
+	}
+
+	if s.file(s.LockPath()) {
+		if pid, err := os.ReadFile(s.LockPath()); err != nil {
+			return fmt.Errorf("stored locked. error file read error: %s", err)
+		} else {
+			return fmt.Errorf("stored locked by process %s", string(pid))
+		}
+	}
+	if err := os.WriteFile(s.LockPath(), []byte(strconv.Itoa(os.Getpid())), s.PermissionFile); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Close closes the Store.
+func (s *Store) Close() error {
+	for variant, ldb := range s.pageldb {
+		if err := ldb.Close(); err != nil {
+			log.Printf("error page variant %s close: %s", variant, err)
+			return err
+		} else {
+			if s.verbose {
+				log.Printf("page variant %s closed", variant)
+			}
+		}
+	}
+
+	if err := os.Remove(s.LockPath()); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// Name returns name of the Backend.
+func (s *Store) Name() string {
+	return "filesystem"
+}
+
+// upgrade the Store to a new format.
+func (s *Store) upgrade() error {
+	// upgrade code goes here
+
+	return fmt.Errorf("upgrading from revision %d to %d is not supported", s.Revision, revision)
+}
+
+// create sets up the store directory if it does not exist.
+func (s *Store) create() error {
+	if _, err := os.Stat(s.path); err == nil {
+		return store.ErrAlreadyExists
+	}
+
+	if err := os.Mkdir(s.path, s.PermissionDir); err != nil {
+		return err
+	}
+	if err := os.Mkdir(s.TagsDir(), s.PermissionDir); err != nil {
+		return err
+	}
+	if err := os.Mkdir(s.PageBaseDir(), s.PermissionDir); err != nil {
+		return err
+	}
+
+	if err := os.MkdirAll(s.ImagesHashDir(), s.PermissionDir); err != nil {
+		return err
+	}
+	if err := os.Mkdir(s.ImagesSnowflakeDir(), s.PermissionDir); err != nil {
+		return err
+	}
+
+	if err := os.MkdirAll(s.UsersDir(), s.PermissionDir); err != nil {
+		return err
+	}
+	if err := os.Mkdir(s.TombstoneDir(), s.PermissionDir); err != nil {
+		return err
+	}
+	if err := os.Mkdir(s.SecretsDir(), s.PermissionDir); err != nil {
+		return err
+	}
+	if err := os.Mkdir(s.UsernamesDir(), s.PermissionDir); err != nil {
+		return err
+	}
+
+	if info, err := s.UserAdd("root", store.InitialPassword, true); err != nil {
+		log.Fatalf("error adding initial user: %s", err)
+	} else {
+		log.Printf("initial user added with username \"root\"")
+		s.InitialUser = info.Snowflake
+	}
+
+	// Create information file
+	if payload, err := json.Marshal(s); err != nil {
+		return err
+	} else {
+		if err = os.WriteFile(s.path+"/"+infoJson, payload, s.PermissionFile); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// getLock returns a lock associated with a string.
+func (s *Store) getLock(entry string) *sync.RWMutex {
+	s.giant.RLock()
+	mutex, ok := s.mutex[entry]
+	s.giant.RUnlock()
+	if !ok {
+		s.giant.Lock()
+		mutex = &sync.RWMutex{}
+		s.mutex[entry] = mutex
+		s.giant.Unlock()
+	}
+	return mutex
+}
+
+// file probes for the existence of specified entry on the filesystem.
+func (s *Store) file(path string) bool {
+	if _, err := os.Stat(path); err != nil {
+		if os.IsNotExist(err) {
+			return false
+		} else {
+			if s.verbose {
+				log.Printf("file warning: %s stat error: %s", path, err)
+			}
+			return true
+		}
+	}
+	return true
+}
+
+// dir probes for the presence of specified directory on the filesystem.
+func (s *Store) dir(path string) bool {
+	if stat, err := os.Stat(path); err != nil {
+		if os.IsNotExist(err) {
+			return false
+		} else {
+			if s.verbose {
+				log.Printf("dir warning: %s stat error: %s", path, err)
+			}
+			return false
+		}
+	} else {
+		if !stat.IsDir() {
+			if s.verbose {
+				log.Printf("dir warning: %s is not a directory", path)
+			}
+			return false
+		}
+	}
+	return true
+}
+
+// link provides symlink-like usage with window compatibility.
+func (s *Store) link(old, new string) error {
+	if !s.Compat {
+		if err := os.Symlink(old, new); err != nil {
+			return err
+		} else {
+			return nil
+		}
+	} else {
+		if err := os.WriteFile(new, []byte(old), s.PermissionFile); err != nil {
+			return err
+		} else {
+			return nil
+		}
+	}
+}
+
+// readlink provides readlink-like usage with window compatibility.
+func (s *Store) readlink(path string) (string, error) {
+	if !s.Compat {
+		//return os.Readlink(path)
+		return path, nil
+	} else {
+		if final, err := os.ReadFile(path); err != nil {
+			if os.IsNotExist(err) {
+				return "", store.ErrNoEntry
+			}
+			return "", err
+		} else {
+			return string(final), nil
+		}
+	}
+}
diff --git a/backend/filesystem/tag.go b/backend/filesystem/tag.go
new file mode 100644
index 0000000..ec03e74
--- /dev/null
+++ b/backend/filesystem/tag.go
@@ -0,0 +1,148 @@
+package filesystem
+
+import (
+	"encoding/json"
+	"log"
+	"os"
+	"random.chars.jp/git/image-board/v2/store"
+	"time"
+)
+
+// Tags returns a slice of tag names.
+func (s *Store) Tags() ([]string, error) {
+	var tags []string
+	if entries, err := os.ReadDir(s.TagsDir()); err != nil {
+		return nil, err
+	} else {
+		for _, entry := range entries {
+			if entry.IsDir() {
+				tags = append(tags, entry.Name())
+			}
+		}
+	}
+	return tags, nil
+}
+
+// Tag returns information of a tag.
+func (s *Store) Tag(tag string) (*store.Tag, error) {
+	if !store.MatchName(tag) {
+		return nil, store.ErrInvalidInput
+	} else if !s.file(s.TagMetadataPath(tag)) {
+		return nil, store.ErrNoEntry
+	}
+
+	s.getLock("tag_" + tag).RLock()
+	defer s.getLock("tag_" + tag).RUnlock()
+
+	if payload, err := os.ReadFile(s.TagMetadataPath(tag)); err != nil {
+		return nil, err
+	} else {
+		var info store.Tag
+		err = json.Unmarshal(payload, &info)
+		return &info, err
+	}
+}
+
+// TagImages returns a slice of image snowflakes in a specific tag.
+func (s *Store) TagImages(tag string) ([]string, error) {
+	if !store.MatchName(tag) {
+		return nil, store.ErrInvalidInput
+	} else if !s.dir(s.TagPath(tag)) {
+		return nil, store.ErrNoEntry
+	}
+	var images []string
+	if entries, err := os.ReadDir(s.TagPath(tag)); err != nil {
+		return nil, err
+	} else {
+		for _, entry := range entries {
+			if entry.Name() == infoJson {
+				continue
+			}
+			images = append(images, entry.Name())
+		}
+	}
+	return images, nil
+}
+
+// TagAdd creates a tag.
+func (s *Store) TagAdd(tag string) error {
+	if len(tag) > 128 || !store.MatchName(tag) {
+		return store.ErrInvalidInput
+	} else if s.file(s.TagPath(tag)) {
+		return store.ErrAlreadyExists
+	}
+
+	s.getLock("tag_" + tag).Lock()
+	defer s.getLock("tag_" + tag).Unlock()
+	if err := os.Mkdir(s.TagPath(tag), s.PermissionDir); err != nil {
+		return err
+	}
+	if payload, err := json.Marshal(store.Tag{Type: store.GenericType, CreationTime: time.Now().UTC()}); err != nil {
+		return err
+	} else {
+		if err = os.WriteFile(s.TagMetadataPath(tag), payload, s.PermissionFile); err != nil {
+			return err
+		} else {
+			log.Printf("tag %s added", tag)
+			return nil
+		}
+	}
+}
+
+// TagDestroy removes all references from a tag and removes it.
+func (s *Store) TagDestroy(tag string) error {
+	if !store.MatchName(tag) {
+		return store.ErrInvalidInput
+	} else if !s.dir(s.TagPath(tag)) {
+		return store.ErrNoEntry
+	}
+
+	if flakes, err := s.TagImages(tag); err != nil {
+		return err
+	} else {
+		for _, flake := range flakes {
+			if err = s.ImageTagRemove(flake, tag); err != nil {
+				return err
+			}
+		}
+	}
+
+	if err := os.Remove(s.TagMetadataPath(tag)); err != nil {
+		return err
+	}
+	if err := os.Remove(s.TagPath(tag)); err != nil {
+		return err
+	}
+
+	log.Printf("tag %s destroyed", tag)
+	return nil
+}
+
+// TagType sets type of tag.
+func (s *Store) TagType(tag, t string) error {
+	if !store.MatchName(tag) || !store.MatchTagType(t) {
+		return store.ErrInvalidInput
+	} else if !s.file(s.TagMetadataPath(tag)) {
+		return store.ErrNoEntry
+	}
+
+	if info, err := s.Tag(tag); err != nil {
+		return err
+	} else {
+		s.getLock("tag_" + tag).Lock()
+		defer s.getLock("tag_" + tag).Unlock()
+
+		info.Type = t
+		var payload []byte
+		if payload, err = json.Marshal(info); err != nil {
+			return err
+		} else {
+			if err = os.WriteFile(s.TagMetadataPath(tag), payload, s.PermissionFile); err != nil {
+				return err
+			} else {
+				log.Printf("tag %s type set to %s", tag, t)
+				return nil
+			}
+		}
+	}
+}
diff --git a/backend/filesystem/user.go b/backend/filesystem/user.go
new file mode 100644
index 0000000..5d3958c
--- /dev/null
+++ b/backend/filesystem/user.go
@@ -0,0 +1,381 @@
+package filesystem
+
+import (
+	"encoding/json"
+	"log"
+	"os"
+	"random.chars.jp/git/image-board/v2/store"
+	"time"
+)
+
+// UserInitial returns flake of initial user.
+func (s *Store) UserInitial() string {
+	return s.InitialUser
+}
+
+// user parses user metadata file.
+func (s *Store) user(path string) (*store.User, error) {
+	if payload, err := os.ReadFile(path); err != nil {
+		return nil, err
+	} else {
+		var info store.User
+		if err = json.Unmarshal(payload, &info); err != nil {
+			return nil, err
+		} else {
+			return &info, nil
+		}
+	}
+}
+
+// User returns user information with specific snowflake.
+func (s *Store) User(flake string) (*store.User, error) {
+	if !store.Numerical(flake) {
+		return nil, store.ErrInvalidInput
+	} else if !s.file(s.UserPath(flake)) {
+		return nil, store.ErrNoEntry
+	}
+
+	s.getLock(flake).RLock()
+	defer s.getLock(flake).RUnlock()
+	return s.user(s.UserMetadataPath(flake))
+}
+
+// Users returns a slice of user snowflakes.
+func (s *Store) Users() ([]string, error) {
+	var users []string
+	if entries, err := os.ReadDir(s.UsersDir()); err != nil {
+		return nil, err
+	} else {
+		for _, entry := range entries {
+			if entry.IsDir() {
+				users = append(users, entry.Name())
+			}
+		}
+	}
+	return users, nil
+}
+
+// userMetadata sets user metadata.
+func (s *Store) userMetadata(info *store.User) error {
+	if payload, err := json.Marshal(info); err != nil {
+		return err
+	} else {
+		return os.WriteFile(s.UserMetadataPath(info.Snowflake), payload, s.PermissionFile)
+	}
+}
+
+// UserAdd creates a user.
+func (s *Store) UserAdd(username, password string, privileged bool) (*store.User, error) {
+	if len(username) > 64 || !store.MatchName(username) || !store.MatchPassword(password) {
+		return nil, store.ErrInvalidInput
+	} else if s.file(s.UsernamePath(username)) {
+		return nil, store.ErrAlreadyExists
+	}
+
+	var secret string
+	if se, err := store.SecretNew(); err != nil {
+		return nil, err
+	} else {
+		secret = se
+	}
+	info := store.User{
+		Secret:     secret,
+		Privileged: privileged,
+		Snowflake:  store.MakeFlake(store.UserNode).String(),
+		Username:   username,
+	}
+	// Create user directory and images
+	if err := os.MkdirAll(s.UserImagesPath(info.Snowflake), s.PermissionDir); err != nil {
+		return nil, err
+	}
+
+	s.getLock(info.Snowflake).Lock()
+	defer s.getLock(info.Snowflake).Unlock()
+
+	if err := s.userMetadata(&info); err != nil {
+		return nil, err
+	}
+	if err := s.userUsernameAssociate(info.Snowflake, info.Username); err != nil {
+		return nil, err
+	}
+	if err := s.userPasswordUpdate(s.UserPath(info.Snowflake), password); err != nil {
+		return nil, err
+	}
+	if err := s.secretAssociate(info.Secret, info.Snowflake); err != nil {
+		return nil, err
+	}
+
+	log.Printf("user %s added with username %s privilege %v secret %s.",
+		info.Snowflake, info.Username, info.Privileged, info.Secret)
+	return &info, nil
+}
+
+// UserPrivileged sets privileged status of user with specific snowflake.
+func (s *Store) UserPrivileged(flake string, privileged bool) error {
+	if !store.Numerical(flake) {
+		return store.ErrInvalidInput
+	}
+
+	s.getLock(flake).Lock()
+	defer s.getLock(flake).Unlock()
+
+	if info, err := s.user(s.UserMetadataPath(flake)); err != nil {
+		return err
+	} else {
+		info.Privileged = privileged
+		if err = s.userMetadata(info); err != nil {
+			return err
+		} else {
+			log.Printf("user %s privileged %v", flake, privileged)
+			return nil
+		}
+	}
+}
+
+// UserUsernameUpdate updates username of user with specific snowflake.
+func (s *Store) UserUsernameUpdate(flake, username string) error {
+	if !store.Numerical(flake) || !store.MatchName(username) {
+		return store.ErrInvalidInput
+	} else if s.file(s.UsernamePath(username)) {
+		return store.ErrAlreadyExists
+	}
+
+	s.getLock(flake).Lock()
+	defer s.getLock(flake).Unlock()
+
+	if info, err := s.user(s.UserMetadataPath(flake)); err != nil {
+		return err
+	} else {
+		s.getLock(info.Username).Lock()
+		defer s.getLock(info.Username).Unlock()
+
+		if info.Username != "" {
+			if err = s.userUsernameDisassociate(info.Username); err != nil {
+				return err
+			}
+		}
+		if err = s.userUsernameAssociate(flake, username); err != nil {
+			return err
+		}
+
+		info.Username = username
+		if err = s.userMetadata(info); err != nil {
+			return err
+		}
+		log.Printf("user %s username updated to %s.", flake, username)
+		return nil
+	}
+
+}
+
+// UserSecretRegen regenerates secret of user with specific snowflake.
+func (s *Store) UserSecretRegen(flake string) (string, error) {
+	if !store.Numerical(flake) {
+		return "", store.ErrInvalidInput
+	}
+
+	s.getLock(flake).Lock()
+	defer s.getLock(flake).Unlock()
+
+	if info, err := s.user(s.UserMetadataPath(flake)); err != nil {
+		return "", err
+	} else {
+		// Disassociate old user
+		if err = s.secretDisassociate(info.Secret); err != nil {
+			return "", err
+		}
+		// Generate new secret
+		if info.Secret, err = store.SecretNew(); err != nil {
+			return "", err
+		}
+		// Write metadata
+		if err = s.userMetadata(info); err != nil {
+			return "", err
+		}
+		// Associate new secret
+		if err = s.secretAssociate(info.Secret, info.Snowflake); err != nil {
+			return "", err
+		}
+
+		log.Printf("user %s secret reset to %s", flake, info.Secret)
+		return info.Secret, nil
+	}
+}
+
+// UserUsername returns user via username.
+func (s *Store) UserUsername(username string) (*store.User, error) {
+	if !store.MatchName(username) {
+		return nil, store.ErrInvalidInput
+	} else if !s.file(s.UsernamePath(username)) {
+		return nil, store.ErrNoEntry
+	}
+
+	s.getLock(username).RLock()
+	defer s.getLock(username).RUnlock()
+
+	var rl string
+	if r, err := s.readlink(s.UsernamePath(username)); err != nil {
+		return nil, err
+	} else {
+		rl = r
+	}
+	if user, err := s.user(rl + "/" + infoJson); err != nil {
+		return nil, err
+	} else {
+		return user, nil
+	}
+}
+
+// userUsernameAssociate associates user snowflake with specific username.
+func (s *Store) userUsernameAssociate(flake, username string) error {
+	return s.link("../users/"+flake, s.UsernamePath(username))
+}
+
+// userUsernameDisassociate disassociates specific username.
+func (s *Store) userUsernameDisassociate(username string) error {
+	return os.Remove(s.UsernamePath(username))
+}
+
+// userPassword returns password of user from path to user directory.
+func (s *Store) userPassword(path string) (string, error) {
+	if payload, err := os.ReadFile(path + "/passwd"); err != nil {
+		if os.IsNotExist(err) {
+			return "", store.ErrNoEntry
+		}
+		return "", err
+	} else {
+		return string(payload), nil
+	}
+}
+
+// userPasswordUpdate updates user password of user from path to user directory.
+func (s *Store) userPasswordUpdate(path, password string) error {
+	return os.WriteFile(path+"/passwd", []byte(password), s.PermissionFile)
+}
+
+// UserPasswordValidate validates password of specified user.
+func (s *Store) UserPasswordValidate(flake, username *string, password string) (bool, error) {
+	if flake != nil {
+		if !store.Numerical(*flake) || !store.MatchPassword(password) {
+			return false, store.ErrInvalidInput
+		} else if !s.file(s.UserPath(*flake)) {
+			return false, store.ErrNoEntry
+		}
+
+		s.getLock(*flake).RLock()
+		defer s.getLock(*flake).RUnlock()
+
+		if p, err := s.userPassword(s.UserPath(*flake)); err != nil {
+			return false, err
+		} else {
+			return password != "" && password == p, nil
+		}
+	} else if username != nil {
+		if !store.MatchName(*username) || !store.MatchPassword(password) {
+			return false, store.ErrInvalidInput
+		} else if !s.file(s.UsernamePath(*username)) {
+			return false, store.ErrNoEntry
+		}
+
+		s.getLock(*username).RLock()
+		defer s.getLock(*username).RUnlock()
+
+		var p string
+		if r, err := s.readlink(s.UsernamePath(*username)); err != nil {
+			return false, err
+		} else {
+			if p, err = s.userPassword(r); err != nil {
+				return false, err
+			}
+		}
+
+		return password != "" && password == p, nil
+	} else {
+		return false, store.ErrInvalidInput
+	}
+}
+
+// UserPasswordUpdate updates password of specified user.
+func (s *Store) UserPasswordUpdate(flake, password string) error {
+	if !store.Numerical(flake) || !store.MatchPassword(password) {
+		return store.ErrInvalidInput
+	}
+
+	s.getLock(flake).Lock()
+	defer s.getLock(flake).Unlock()
+
+	return s.userPasswordUpdate(s.UserPath(flake), password)
+}
+
+// UserDestroy destroys a user with specific snowflake.
+func (s *Store) UserDestroy(flake string) error {
+	if !store.Numerical(flake) {
+		return store.ErrInvalidInput
+	}
+	if !s.dir(s.UserPath(flake)) {
+		return store.ErrNoEntry
+	}
+
+	if info, err := s.User(flake); err != nil {
+		return err
+	} else {
+		s.getLock(info.Snowflake).Lock()
+		defer s.getLock(info.Snowflake).Unlock()
+
+		if err = s.secretDisassociate(info.Secret); err != nil {
+			return err
+		}
+		if err = s.userUsernameDisassociate(info.Username); err != nil {
+			return err
+		}
+
+		if err = os.Rename(s.UserPath(flake), s.TombstonePath(flake)); err != nil {
+			return err
+		}
+
+		var tombstone *os.File
+		if tombstone, err = os.Create(s.TombstoneMetadataPath(flake)); err != nil {
+			return err
+		} else {
+			if err = json.NewEncoder(tombstone).Encode(store.Tombstone{Time: int(time.Now().Unix())}); err != nil {
+				return err
+			} else if err = tombstone.Close(); err != nil {
+				return err
+			}
+			log.Printf("user %s username %s destroyed", info.Snowflake, info.Username)
+			return nil
+		}
+	}
+}
+
+// UserImages returns slice of a user's images.
+func (s *Store) UserImages(flake string) ([]string, error) {
+	if !store.Numerical(flake) {
+		return nil, store.ErrInvalidInput
+	}
+	if !s.dir(s.UserImagesPath(flake)) {
+		return nil, store.ErrNoEntry
+	}
+
+	var images []string
+	if entries, err := os.ReadDir(s.UserImagesPath(flake)); err != nil {
+		return nil, err
+	} else {
+		for _, entry := range entries {
+			images = append(images, entry.Name())
+		}
+	}
+	return images, nil
+}
+
+// UserImage validates whether a user owns an Image.
+func (s *Store) UserImage(flake, imageFlake string) (bool, error) {
+	if !store.Numerical(flake) {
+		return false, store.ErrInvalidInput
+	}
+	if !s.dir(s.UserImagesPath(flake)) {
+		return false, store.ErrNoEntry
+	}
+
+	return s.dir(s.UserImagesPath(flake) + "/" + imageFlake), nil
+}
diff --git a/cleanup.go b/cleanup.go
index 9925b6b..cfe659d 100644
--- a/cleanup.go
+++ b/cleanup.go
@@ -2,25 +2,18 @@ package main
 
 import (
 	"context"
-	log "github.com/sirupsen/logrus"
+	"log"
 	"time"
 )
 
-func cleanup(restart bool) {
-	var err error
-
-	// Set restart
-	d = true
-	r = restart
-
-	// Close store
-	instance.Close()
+func cleanup() {
+	if err := instance.Close(); err != nil {
+		log.Printf("error closing instance: %s", err)
+	}
 
-	// Shutdown web server
 	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
 	defer cancel()
-	err = server.Shutdown(ctx)
-	if err != nil {
-		log.Errorf("Error while shutting down web server, %s", err)
+	if err := server.Shutdown(ctx); err != nil {
+		log.Printf("error shutting down web server: %s", err)
 	}
 }
diff --git a/client/image.go b/client/image.go
deleted file mode 100644
index 6fcd8ed..0000000
--- a/client/image.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package client
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"mime/multipart"
-	"net/http"
-	"random.chars.jp/git/image-board/api"
-	"random.chars.jp/git/image-board/store"
-	"strconv"
-)
-
-// Images returns a slice of snowflakes of all images. Only available to privileged users.
-func (r *Remote) Images() ([]string, error) {
-	var flakes []string
-	err := r.fetch(http.MethodGet, api.Image, &flakes, nil)
-	return flakes, err
-}
-
-// ImageAdd adds an image to Remote and returns a store.Image.
-func (r *Remote) ImageAdd(reader io.Reader) (store.Image, error) {
-	if c, ok := reader.(io.Closer); ok {
-		defer func() {
-			if err := c.Close(); err != nil {
-				fmt.Printf("Error closing Closer, %s", err)
-			}
-		}()
-	}
-
-	buf := &bytes.Buffer{}
-	w := multipart.NewWriter(buf)
-	if f, err := w.CreateFormFile("image", "image"); err != nil {
-		return store.Image{}, err
-	} else {
-		if _, err = io.Copy(f, reader); err != nil {
-			return store.Image{}, err
-		}
-	}
-
-	if err := w.Close(); err != nil {
-		return store.Image{}, err
-	}
-
-	if req, err := http.NewRequest(http.MethodPost, r.URL(api.Image), buf); err != nil {
-		return store.Image{}, err
-	} else {
-		req.Header.Set("Content-Type", w.FormDataContentType())
-		var resp *http.Response
-		if resp, err = r.send(req); err != nil {
-			return store.Image{}, err
-		} else {
-			var image store.Image
-			err = unmarshal(resp.Body, &image)
-			return image, err
-		}
-	}
-}
-
-// Image returns store.Image with given snowflake.
-func (r *Remote) Image(flake string) (store.Image, error) {
-	var image store.Image
-	err := r.fetch(http.MethodGet, populateField(api.ImageField, "flake", flake), &image, nil)
-	return image, err
-}
-
-// ImageGroup returns an entire group of store.Image.
-func (r *Remote) ImageGroup(flake string) ([]store.Image, error) {
-	if image, err := r.Image(flake); err != nil {
-		return nil, err
-	} else {
-		var group []store.Image
-
-		// Iterate forwards
-		if err = func(image store.Image) error {
-			group = []store.Image{image}
-			for image.Child != "" {
-				if image, err = r.Image(image.Child); err != nil {
-					return err
-				}
-				group = append(group, image)
-			}
-			return nil
-		}(image); err != nil {
-			return group, err
-		}
-
-		// Iterate backwards
-		if err = func(image store.Image) error {
-			for image.Parent != "" {
-				if image, err = r.Image(image.Parent); err != nil {
-					return err
-				}
-				group = append([]store.Image{image}, group...)
-			}
-			return nil
-		}(image); err != nil {
-			return group, err
-		}
-
-		return group, nil
-	}
-}
-
-// ImageUpdate updates metadata of store.Image with given snowflake. To persist original value in a field set \000.
-func (r *Remote) ImageUpdate(flake, source, parent, commentary, commentaryTranslation string) error {
-	payload := api.ImageUpdatePayload{
-		Source:                source,
-		Parent:                parent,
-		Commentary:            commentary,
-		CommentaryTranslation: commentaryTranslation,
-	}
-	return r.requestJSONnoResp(http.MethodPatch, populateField(api.ImageField, "flake", flake), payload)
-}
-
-// ImageDestroy destroys a store.Image with given snowflake.
-func (r *Remote) ImageDestroy(flake string) error {
-	return r.requestNoResp(http.MethodDelete, populateField(api.ImageField, "flake", flake), nil)
-}
-
-// ImageFile returns a slice of bytes of the image with given snowflake.
-func (r *Remote) ImageFile(flake string, preview bool) ([]byte, error) {
-	switch preview {
-	case true:
-		return r.fetchAll(http.MethodGet, populateField(api.ImagePreview, "flake", flake), nil)
-	case false:
-		return r.fetchAll(http.MethodGet, populateField(api.ImageFile, "flake", flake), nil)
-	default:
-		return nil, nil
-	}
-}
-
-// ImageTag returns a slice of tags of an image with given snowflake.
-func (r *Remote) ImageTag(flake string) ([]string, error) {
-	var tags []string
-	err := r.fetch(http.MethodGet, populateField(api.ImageTag, "flake", flake), &tags, nil)
-	return tags, err
-}
-
-// ImageTagAdd adds a tag to an image with given snowflake.
-func (r *Remote) ImageTagAdd(flake, tag string) error {
-	return r.requestNoResp(http.MethodPut,
-		populateField(populateField(api.ImageTagField,
-			"flake", flake),
-			"tag", tag),
-		nil)
-}
-
-// ImageTagRemove removes a tag from an image with given snowflake.
-func (r *Remote) ImageTagRemove(flake, tag string) error {
-	return r.requestNoResp(http.MethodDelete,
-		populateField(populateField(api.ImageTagField,
-			"flake", flake),
-			"tag", tag),
-		nil)
-}
-
-// ImagePages returns total amount of image pages.
-func (r *Remote) ImagePages() (int, error) {
-	if payload, err := r.fetchAll(http.MethodGet, api.ImagePage, nil); err != nil {
-		return 0, err
-	} else {
-		var pages int
-		if pages, err = strconv.Atoi(string(payload)); err != nil {
-			return 0, err
-		} else {
-			return pages, nil
-		}
-	}
-}
-
-// ImagePage returns a slice of snowflakes of images in a page.
-func (r *Remote) ImagePage(entry int) ([]string, error) {
-	var flakes []string
-	err := r.fetch(http.MethodGet, populateField(api.ImagePageField, "entry", strconv.Itoa(entry)), &flakes, nil)
-	return flakes, err
-}
-
-// ImagePageImage returns a slice of store.Image in a page.
-func (r *Remote) ImagePageImage(entry int) ([]store.Image, error) {
-	var images []store.Image
-	err := r.fetch(http.MethodGet, populateField(api.ImagePageImage, "entry", strconv.Itoa(entry)), &images, nil)
-	return images, err
-}
-
-// ImageSearch searches for images that contains all specified tags.
-func (r *Remote) ImageSearch(tags []string) ([]string, error) {
-	t := tags[0]
-	for i := 1; i < len(tags); i++ {
-		t += "!" + tags[i]
-	}
-	var flakes []string
-	err := r.fetch(http.MethodGet, populateField(api.SearchField, "tags", t), &flakes, nil)
-	return flakes, err
-}
diff --git a/client/js/README b/client/js/README
deleted file mode 100644
index f827973..0000000
--- a/client/js/README
+++ /dev/null
@@ -1 +0,0 @@
-This is for code generation only.
\ No newline at end of file
diff --git a/client/js/main.go b/client/js/main.go
deleted file mode 100644
index caecbdd..0000000
--- a/client/js/main.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package main
-
-//go:generate gopherjs build -o client.js
-//go:generate gopherjs build -o client.min.js --minify
-
-import (
-	"github.com/gopherjs/gopherjs/js"
-	"random.chars.jp/git/image-board/client"
-)
-
-func main() {
-	js.Global.Set("newRemote", client.New)
-}
diff --git a/client/misc.go b/client/misc.go
deleted file mode 100644
index b37f568..0000000
--- a/client/misc.go
+++ /dev/null
@@ -1,7 +0,0 @@
-package client
-
-import "strings"
-
-func populateField(path, field, content string) string {
-	return strings.Replace(path, ":"+field, content, 1)
-}
diff --git a/client/remote.go b/client/remote.go
deleted file mode 100644
index 34a0684..0000000
--- a/client/remote.go
+++ /dev/null
@@ -1,71 +0,0 @@
-package client
-
-import (
-	"fmt"
-	"net/http"
-	"random.chars.jp/git/image-board/api"
-	"random.chars.jp/git/image-board/store"
-)
-
-// Remote represents a remote image board server.
-type Remote struct {
-	url     string
-	single  bool
-	private bool
-	secret  string
-	client  *http.Client
-	user    *api.UserPayload
-	store.Info
-}
-
-// New returns a pointer to a new Remote.
-func New(url string) (*Remote, error) {
-	remote := &Remote{url: url, client: &http.Client{}}
-	return remote, remote.Handshake()
-}
-
-// URL returns the URL of Remote.
-func (r *Remote) URL(path string) string {
-	return r.url + path
-}
-
-// SingleUser returns whether the Remote is running in single user mode.
-func (r *Remote) SingleUser() bool {
-	return r.single
-}
-
-// Private returns whether the Remote is running in private mode.
-func (r *Remote) Private() bool {
-	return r.private
-}
-
-// Handshake checks if the server is still online and updates Remote.
-func (r *Remote) Handshake() error {
-	if err := r.fetch(http.MethodGet, api.SingleUser, &r.single, nil); err != nil {
-		return err
-	}
-	if err := r.fetch(http.MethodGet, api.Private, &r.private, nil); err != nil {
-		return err
-	}
-	return r.fetch(http.MethodGet, api.Base, r, nil)
-}
-
-// Secret authenticates and sets secret.
-func (r *Remote) Secret(secret string) (api.UserPayload, bool) {
-	// Clear secret if empty
-	if secret == "" {
-		r.secret = secret
-		return api.UserPayload{}, true
-	}
-
-	prev := r.secret
-	r.secret = secret
-	if user, err := r.This(); err != nil {
-		fmt.Printf("Error getting user post secret change, %s", err)
-		r.secret = prev
-		return api.UserPayload{}, false
-	} else {
-		r.user = &user
-		return user, true
-	}
-}
diff --git a/client/request.go b/client/request.go
deleted file mode 100644
index 93ada62..0000000
--- a/client/request.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package client
-
-import (
-	"bytes"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"io"
-	"net/http"
-	"random.chars.jp/git/image-board/api"
-)
-
-func (r *Remote) send(req *http.Request) (*http.Response, error) {
-	if req == nil {
-		return nil, nil
-	}
-
-	if r.secret != "" {
-		req.Header.Add("secret", r.secret)
-	}
-
-	return r.client.Do(req)
-}
-
-func (r *Remote) request(method, path string, body io.Reader) (*http.Response, error) {
-	if req, err := http.NewRequest(method, r.URL(path), body); err != nil {
-		return nil, err
-	} else {
-		return r.send(req)
-	}
-}
-
-func (r *Remote) requestNoResp(method, path string, body io.Reader) error {
-	if resp, err := r.request(method, path, body); err != nil {
-		return err
-	} else {
-		return resp.Body.Close()
-	}
-}
-
-func (r *Remote) requestJSON(method, path string, v interface{}) (*http.Response, error) {
-	var reader *bytes.Reader
-	if v != nil {
-		if payload, err := json.Marshal(v); err != nil {
-			return nil, err
-		} else {
-			reader = bytes.NewReader(payload)
-		}
-	}
-	return r.request(method, path, reader)
-}
-
-func (r *Remote) requestJSONnoResp(method, path string, v interface{}) error {
-	if resp, err := r.requestJSON(method, path, v); err != nil {
-		return err
-	} else {
-		return resp.Body.Close()
-	}
-}
-
-func (r *Remote) fetch(method, path string, v interface{}, body io.Reader) error {
-	if resp, err := r.request(method, path, body); err != nil {
-		return err
-	} else {
-		if v == nil {
-			return nil
-		}
-		if err = unmarshal(resp.Body, v); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func (r *Remote) fetchAll(method, path string, body io.Reader) ([]byte, error) {
-	if resp, err := r.request(method, path, body); err != nil {
-		return nil, err
-	} else {
-		defer func() {
-			if err = resp.Body.Close(); err != nil {
-				fmt.Printf("Error closing body after reading all, %s\n", err)
-			}
-		}()
-		var content []byte
-		if content, err = io.ReadAll(resp.Body); err != nil {
-			return nil, err
-		} else {
-			return content, nil
-		}
-	}
-}
-
-func unmarshal(reader io.ReadCloser, v interface{}) error {
-	defer func() {
-		if err := reader.Close(); err != nil {
-			fmt.Printf("Error closing reader after unmarshalling, %s\n", err)
-		}
-	}()
-
-	var data []byte
-	if d, err := io.ReadAll(reader); err != nil {
-		return err
-	} else {
-		data = d
-	}
-
-	if err := json.Unmarshal(data, v); err != nil {
-		var errPayload api.Error
-		if tryErr := json.Unmarshal(data, &errPayload); tryErr == nil {
-			return errors.New(errPayload.Error)
-		}
-		return err
-	}
-	return nil
-}
diff --git a/client/tag.go b/client/tag.go
deleted file mode 100644
index 37b8745..0000000
--- a/client/tag.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package client
-
-import (
-	"net/http"
-	"random.chars.jp/git/image-board/api"
-	"random.chars.jp/git/image-board/store"
-	"strconv"
-)
-
-// Tags returns a slice of tag names on Remote.
-func (r *Remote) Tags() ([]string, error) {
-	var tags []string
-	err := r.fetch(http.MethodGet, api.Tag, &tags, nil)
-	return tags, err
-}
-
-// Tag returns a slice of snowflakes in a given tag.
-func (r *Remote) Tag(tag string) ([]string, error) {
-	var flakes []string
-	err := r.fetch(http.MethodGet, populateField(api.TagField, "tag", tag), &flakes, nil)
-	return flakes, err
-}
-
-// TagPage returns a slice of snowflakes in a page of a given tag.
-func (r *Remote) TagPage(tag string, entry int) ([]string, error) {
-	var flakes []string
-	err := r.fetch(http.MethodGet,
-		populateField(populateField(api.TagPageField,
-			"tag", tag),
-			"entry", strconv.Itoa(entry)),
-		&flakes, nil)
-	return flakes, err
-}
-
-// TagPageImages returns a slice of store.Image in a page of a given tag.
-func (r *Remote) TagPageImages(tag string, entry int) ([]store.Image, error) {
-	var images []store.Image
-	err := r.fetch(http.MethodGet,
-		populateField(populateField(api.TagPageImage,
-			"tag", tag),
-			"entry", strconv.Itoa(entry)),
-		&images, nil)
-	return images, err
-}
-
-// TagPages returns total amount of pages of a tag.
-func (r *Remote) TagPages(tag string) (int, error) {
-	if payload, err := r.fetchAll(http.MethodGet,
-		populateField(api.TagPage, "tag", tag), nil); err != nil {
-		return 0, err
-	} else {
-		var pages int
-		if pages, err = strconv.Atoi(string(payload)); err != nil {
-			return 0, err
-		} else {
-			return pages, nil
-		}
-	}
-}
-
-// TagCreate creates a tag on Remote.
-func (r *Remote) TagCreate(tag string) error {
-	return r.requestNoResp(http.MethodPut, populateField(api.TagField, "tag", tag), nil)
-}
-
-// TagDestroy destroys a tag on Remote.
-func (r *Remote) TagDestroy(tag string) error {
-	return r.requestNoResp(http.MethodDelete, populateField(api.TagField, "tag", tag), nil)
-}
-
-// TagInfo returns store.Tag of given tag.
-func (r *Remote) TagInfo(tag string) (store.Tag, error) {
-	var t store.Tag
-	err := r.fetch(http.MethodGet, populateField(api.TagInfo, "tag", tag), &t, nil)
-	return t, err
-}
-
-// TagType sets type of given tag.
-func (r *Remote) TagType(tag, t string) error {
-	return r.requestJSONnoResp(http.MethodPatch, populateField(api.TagInfo, "tag", tag), api.TagUpdatePayload{Type: t})
-}
diff --git a/client/user.go b/client/user.go
deleted file mode 100644
index 53c8829..0000000
--- a/client/user.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package client
-
-import (
-	"net/http"
-	"random.chars.jp/git/image-board/api"
-	"random.chars.jp/git/image-board/store"
-	"strings"
-)
-
-// User returns api.UserPayload of a snowflake.
-func (r *Remote) User(flake string) (api.UserPayload, error) {
-	var user api.UserPayload
-	err := r.fetch(http.MethodGet, api.User+"/"+flake, &user, nil)
-	return user, err
-}
-
-// This returns api.UserPayload currently authenticated as.
-func (r *Remote) This() (api.UserPayload, error) {
-	return r.User("this")
-}
-
-// Username returns api.UserPayload of username.
-func (r *Remote) Username(name string) (api.UserPayload, error) {
-	var user api.UserPayload
-	err := r.fetch(http.MethodGet, api.Username+"/"+name, &user, nil)
-	return user, err
-}
-
-// Auth authenticates using a username and its corresponding password.
-func (r *Remote) Auth(username, password string) (string, error) {
-	var secret api.UserSecretPayload
-	err := r.fetch(http.MethodPost,
-		populateField(api.UsernameAuth, "name", username),
-		&secret,
-		strings.NewReader(password))
-	return secret.Secret, err
-}
-
-// UserAdd adds a new user.
-func (r *Remote) UserAdd(username string, password string, privileged bool) (store.User, error) {
-	if resp, err := r.requestJSON(http.MethodPut, api.User, api.UserCreatePayload{
-		Username:   username,
-		Password:   password,
-		Privileged: privileged,
-	}); err != nil {
-		return store.User{}, err
-	} else {
-		var user store.User
-		err = unmarshal(resp.Body, &user)
-		return user, err
-	}
-}
-
-// UserUpdate updates a user.
-func (r *Remote) UserUpdate(flake, newname string, privileged bool) error {
-	return r.requestJSONnoResp(http.MethodPatch,
-		populateField(api.UserField, "flake", flake),
-		api.UserUpdatePayload{Username: newname, Privileged: privileged})
-}
-
-// UserDestroy destroys a user.
-func (r *Remote) UserDestroy(flake string) error {
-	return r.requestJSONnoResp(http.MethodDelete,
-		populateField(api.UserField, "flake", flake),
-		nil)
-}
-
-// UserDisable disables a user.
-func (r *Remote) UserDisable(flake string) error {
-	return r.requestJSONnoResp(http.MethodDelete,
-		populateField(api.UserPassword, "flake", flake),
-		nil)
-}
-
-// UserPassword sets a user's password.
-func (r *Remote) UserPassword(flake, password string) (string, error) {
-	if resp, err := r.request(http.MethodPut,
-		populateField(api.UserPassword, "flake", flake),
-		strings.NewReader(password)); err != nil {
-		return "", err
-	} else {
-		var payload api.UserSecretPayload
-		err = unmarshal(resp.Body, &payload)
-		return payload.Secret, err
-	}
-}
-
-// UserSecret returns a user's secret.
-func (r *Remote) UserSecret(flake string) (string, error) {
-	var secret api.UserSecretPayload
-	err := r.fetch(http.MethodGet, populateField(api.UserSecret, "flake", flake), &secret, nil)
-	return secret.Secret, err
-}
-
-// UserSecretRegen regenerates a user's secret.
-func (r *Remote) UserSecretRegen(flake string) (string, error) {
-	var secret api.UserSecretPayload
-	err := r.fetch(http.MethodPut, populateField(api.UserSecret, "flake", flake), &secret, nil)
-	return secret.Secret, err
-}
-
-// UserImages returns a slice of snowflakes of a user's images.
-func (r *Remote) UserImages(flake string) ([]string, error) {
-	var flakes []string
-	err := r.fetch(http.MethodGet, populateField(api.UserImage, "flake", flake), &flakes, nil)
-	return flakes, err
-}
diff --git a/config.go b/config.go
index b064e14..fe33609 100644
--- a/config.go
+++ b/config.go
@@ -1,99 +1,81 @@
 package main
 
 import (
-	"github.com/fsnotify/fsnotify"
-	log "github.com/sirupsen/logrus"
-	"github.com/spf13/viper"
-	"strconv"
+	"flag"
+	"fmt"
+	"github.com/BurntSushi/toml"
+	"log"
+	"os"
 )
 
+type conf struct {
+	System systemConf `toml:"system"`
+	Server serverConf `toml:"server"`
+}
+
+type serverConf struct {
+	Host  string `toml:"host"`
+	Unix  bool   `toml:"unix"`
+	Port  uint16 `toml:"port"`
+	Proxy bool   `toml:"proxy"`
+}
+
+type systemConf struct {
+	Verbose    bool   `toml:"verbose"`
+	Store      string `toml:"store"`
+	SingleUser bool   `toml:"single-user"`
+	Private    bool   `toml:"private"`
+	Register   bool   `toml:"register"`
+}
+
 var (
-	serverConfig map[string]interface{}
-	systemConfig map[string]interface{}
+	config   conf
+	confPath string
+	confRead = false
 )
 
-func configSetup() {
-	// Configure configuration file parameters
-	viper.SetConfigName("server")
-	viper.SetConfigType("toml")
-	viper.SetConfigPermissions(0600)
-	viper.AddConfigPath(".")
-	viper.AddConfigPath("/etc/imageboard/")
-	viper.AddConfigPath("$HOME/.config/imageboard/")
-
-	// Configure default values
-	viper.SetDefault("system", map[string]interface{}{
-		"loglevel":    "info",
-		"store":       "./db",
-		"single-user": true,
-		"private":     false,
-	})
-	viper.SetDefault("server", map[string]interface{}{
-		"host":  "127.0.0.1",
-		"port":  int64(7777),
-		"unix":  false,
-		"proxy": true,
-	})
+func init() {
+	flag.StringVar(&confPath, "c", "server.conf", "specify path to configuration file")
+}
 
-	// Load configuration
-	log.Info("Loading configuration file.")
-	err := viper.ReadInConfig()
-	if err != nil {
-		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
-			err = viper.WriteConfigAs("server.toml")
-			if err != nil {
-				log.Fatalf("Error generating default configuration, %s", err)
-			}
-			log.Warn("Generated default server.toml in current directory.")
-		} else {
-			log.Fatalf("Error loading configuration, %s", err)
-		}
+func confLoad() {
+	if confRead {
+		panic("configuration read called when already read")
 	}
+	defer func() { confRead = true }()
 
-	if viper.ConfigFileUsed() != "" {
-		log.Infof("Successfully loaded configuration %s, enabling watch.", viper.ConfigFileUsed())
-	}
-	viper.WatchConfig()
-	viper.OnConfigChange(func(event fsnotify.Event) {
-		if d {
-			return
-		}
-		log.Infof("Configuration file %s updated.", event.Name)
-		if viper.GetStringMap("server")["unix"] != serverConfig["unix"] ||
-			viper.GetStringMap("server")["host"] != serverConfig["host"] ||
-			viper.GetStringMap("server")["port"] != serverConfig["port"] ||
-			viper.GetStringMap("system")["single-user"] != systemConfig["single-user"] ||
-			viper.GetStringMap("system")["private"] != systemConfig["private"] {
-			log.Warn("Configuration change requires restart.")
-			cleanup(true)
-			return
+	if meta, err := toml.DecodeFile(confPath, &config); err != nil {
+		if !os.IsNotExist(err) {
+			log.Fatalf("error parsing configuration: %s", err)
 		}
 
-		// Update configuration
-		setLevel()
-		serverConfig = viper.GetStringMap("server")
-		systemConfig = viper.GetStringMap("system")
-	})
-
-	// Read initial config
-	serverConfig = viper.GetStringMap("server")
-	systemConfig = viper.GetStringMap("system")
-}
-
-func parseBool(v interface{}) bool {
-	if s, ok := v.(bool); !ok {
-		var sS string
-		if sS, ok = v.(string); !ok {
-			return false
-		} else {
-			if b, err := strconv.ParseBool(sS); err != nil {
-				log.Warnf("Error parsing boolean value, %s", err)
-				return false
-			} else {
-				return b
-			}
+		var file *os.File
+		if file, err = os.Create(confPath); err != nil {
+			log.Fatalf("error creating configuration file: %s", err)
+		} else if err = toml.NewEncoder(file).Encode(defConf); err != nil {
+			log.Fatalf("error generating default configuration: %s", err)
 		}
+		config = defConf
+		return
 	} else {
-		return s
+		for _, key := range meta.Undecoded() {
+			fmt.Printf("unused key in configuration file: %s", key.String())
+		}
 	}
 }
+
+var defConf = conf{
+	System: systemConf{
+		Verbose:    false,
+		Store:      "db",
+		SingleUser: true,
+		Private:    false,
+		Register:   false,
+	},
+	Server: serverConf{
+		Host:  "127.0.0.1",
+		Unix:  false,
+		Port:  7777,
+		Proxy: true,
+	},
+}
diff --git a/go.mod b/go.mod
index d395498..f5a549b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,15 +1,31 @@
-module random.chars.jp/git/image-board
+module random.chars.jp/git/image-board/v2
 
-go 1.16
+go 1.17
 
 require (
+	github.com/BurntSushi/toml v0.3.1
 	github.com/bwmarrin/snowflake v0.3.0
-	github.com/fsnotify/fsnotify v1.4.9
 	github.com/gin-gonic/gin v1.7.4
-	github.com/gopherjs/gopherjs v0.0.0-20210901121439-eee08aaf2717
 	github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
-	github.com/sirupsen/logrus v1.8.1
-	github.com/spf13/afero v1.1.2 // indirect
-	github.com/spf13/viper v1.7.1
 	github.com/syndtr/goleveldb v1.0.0
 )
+
+require (
+	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/go-playground/locales v0.13.0 // indirect
+	github.com/go-playground/universal-translator v0.17.0 // indirect
+	github.com/go-playground/validator/v10 v10.4.1 // indirect
+	github.com/golang/protobuf v1.3.3 // indirect
+	github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect
+	github.com/json-iterator/go v1.1.9 // indirect
+	github.com/kr/pretty v0.1.0 // indirect
+	github.com/leodido/go-urn v1.2.0 // indirect
+	github.com/mattn/go-isatty v0.0.12 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.1 // indirect
+	github.com/ugorji/go/codec v1.1.7 // indirect
+	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 // indirect
+	golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 // indirect
+	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+)
diff --git a/go.sum b/go.sum
index 75642a4..36db0f8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,430 +1,98 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
-cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
-cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
-cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
-cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
-cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
-cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
-cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
-cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
-cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
-github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
-github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
-github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
-github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
-github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
-github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
-github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
-github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
 github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
 github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
-github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
-github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
-github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
-github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
-github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
-github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
-github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
-github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
-github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
-github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
 github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
-github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
-github.com/gin-gonic/gin v1.7.2 h1:Tg03T9yM2xa8j6I3Z3oqLaQRSmKvxPd6g/2HJ6zICFA=
-github.com/gin-gonic/gin v1.7.2/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
 github.com/gin-gonic/gin v1.7.4 h1:QmUZXrvJ9qZ3GfWvQ+2wnW/1ePrTEJqPKMYEU3lD/DM=
 github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY=
-github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
-github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
-github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
-github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
 github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
 github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
 github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
 github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
-github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
 github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE=
 github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4=
-github.com/go-playground/validator/v10 v10.6.1 h1:W6TRDXt4WcWp4c4nf/G+6BkGdhiIo0k417gfr+V6u4I=
-github.com/go-playground/validator/v10 v10.6.1/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk=
-github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
-github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
-github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
 github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
-github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
-github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
-github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
-github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
-github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
-github.com/gopherjs/gopherjs v0.0.0-20210901121439-eee08aaf2717 h1:V1j4G8AXIJeyzT3ng2Oh4IRo/VEgRWYAsyYwhOz5rko=
-github.com/gopherjs/gopherjs v0.0.0-20210901121439-eee08aaf2717/go.mod h1:0RnbP5ioI0nqRf3R9iK3iQaUJgsn0htlZEHCMn8FSfw=
-github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
-github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
-github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
-github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
-github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
-github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
-github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
-github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
-github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
-github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
-github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
-github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
-github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
-github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
-github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
-github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
-github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
-github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
-github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
-github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
-github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
-github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
-github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
-github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
 github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ=
-github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
-github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
-github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
-github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
-github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
-github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
 github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
-github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
-github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
-github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
-github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
-github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
-github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
-github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
-github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
-github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
-github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
-github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
-github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
-github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
-github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
-github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
-github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
-github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86 h1:D6paGObi5Wud7xg83MaEFyjxQB1W5bz5d0IFppr+ymk=
-github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
-github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c h1:bY6ktFuJkt+ZXkX0RChQch2FtHpWQLVS8Qo1YasiIVk=
-github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
-github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
 github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
-github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
-github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
-github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
-github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
-github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
-github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
-github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
-github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
-github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
-github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
-github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
-github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
-github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
-github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
-github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
-github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
-github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 h1:bUGsEnyNbVPw06Bs80sCeARAlK8lhwqGyi6UT8ymuGk=
-github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
-github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
-github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
-github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
-github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
-github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
-github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
-github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
-github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
-github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8=
-github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
-github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
-github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
-github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
-github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
-github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
-github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
-github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
-github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
 github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
-github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
 github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
 github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
-github.com/ugorji/go v1.2.6 h1:tGiWC9HENWE2tqYycIqFTNorMmFRVhNwCpDOpWqnk8E=
-github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0=
 github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
 github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
-github.com/ugorji/go/codec v1.2.6 h1:7kbGefxLoDBuYXOms4yD7223OpNMMPNPZxXk5TvFcyQ=
-github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw=
-github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
-github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
-github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
-go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
-go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
-go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
-go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
-golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
-golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
-golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
-golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
-golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
-golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
-golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
-golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
-golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
-golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
-golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
-golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57 h1:F5Gozwx4I1xtr/sr/8CFbb57iKi3297KFs0QDbGN60A=
 golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210608053332-aa57babbf139 h1:C+AwYEtBp/VQwoLntUmQ/yx3MS9vmZaKNdw5eOpoQe8=
-golang.org/x/sys v0.0.0-20210608053332-aa57babbf139/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
-golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
-google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
-google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
-google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
-google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
-google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
-google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
-gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno=
-gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
-gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
-rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
diff --git a/log.go b/log.go
deleted file mode 100644
index 47b27c4..0000000
--- a/log.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package main
-
-import (
-	log "github.com/sirupsen/logrus"
-	"github.com/spf13/viper"
-	"go/types"
-)
-
-type logger types.Nil
-
-func (logger) Write(p []byte) (n int, err error) {
-	log.Info(string(p))
-	return len(p), err
-}
-
-func setFormatter() {
-	log.SetFormatter(&log.TextFormatter{
-		ForceColors:               false,
-		DisableColors:             false,
-		ForceQuote:                false,
-		DisableQuote:              false,
-		EnvironmentOverrideColors: false,
-		DisableTimestamp:          false,
-		FullTimestamp:             true,
-		TimestampFormat:           "",
-		DisableSorting:            true,
-		SortingFunc:               nil,
-		DisableLevelTruncation:    false,
-		PadLevelText:              false,
-		QuoteEmptyFields:          false,
-		FieldMap:                  nil,
-		CallerPrettyfier:          nil,
-	})
-}
-
-func setLevel() {
-	level, err := log.ParseLevel(viper.GetStringMap("system")["loglevel"].(string))
-	if err != nil {
-		log.Fatalf("Error parsing log level, %s", err)
-	}
-	log.SetLevel(level)
-}
diff --git a/main.go b/main.go
index febca53..2a1a46b 100644
--- a/main.go
+++ b/main.go
@@ -1,108 +1,77 @@
 package main
 
 import (
-	log "github.com/sirupsen/logrus"
-	"net/http"
+	"flag"
+	"log"
+	"math/rand"
 	"os"
 	"os/signal"
-	"random.chars.jp/git/image-board/store"
+	"random.chars.jp/git/image-board/v2/backend/filesystem"
+	"random.chars.jp/git/image-board/v2/store"
 	"syscall"
+	"time"
 )
 
-var (
-	instance   *store.Store
-	server     = http.Server{}
-	executable string
-)
-
-var (
-	r bool
-	d bool
-)
+var instance store.Store
 
 func init() {
-	setFormatter()
-	var err error
-	executable, err = os.Executable()
-	if err != nil {
-		log.Warnf("Error while obtaining executable path, %s. Restarting will no longer work.", err)
-	}
+	rand.Seed(time.Now().UnixNano())
 }
 
 func main() {
+	flag.Parse()
 
-	// Setup configuration stuff
-	configSetup()
+	confLoad()
 
-	// Initial config update
-	setLevel()
+	// TODO: support more backends
+	instance = filesystem.New(config.System.Store, config.System.Verbose)
+	if err := instance.Open(); err != nil {
+		log.Printf("error opening store: %s", err)
+		return
+	} else {
+		log.Printf("store path %s revision %v compat %v",
+			config.System.Store, instance.(*filesystem.Store).Revision, instance.(*filesystem.Store).Compat)
+	}
 
-	// Open store
-	openStore()
+	if info, err := instance.User(instance.UserInitial()); err == nil {
+		log.Printf("initial user ID %s secret %s.", info.Snowflake, info.Secret)
 
-	// Set up web
-	webSetup()
-
-	// Configure listener
-	listenerSetup()
+		var p bool
+		if p, err = instance.UserPasswordValidate(&info.Snowflake, nil, store.InitialPassword); err != nil {
+			log.Fatalf("error validating password of initial user: %s", err)
+		} else if p {
+			log.Printf("warning: initial user still has the password \"%s\"", store.InitialPassword)
+		}
+	} else {
+		if config.System.SingleUser {
+			log.Fatal("no initial user found, single user mode unavailable")
+		}
+	}
+	if config.System.SingleUser {
+		log.Print("server running single-user, all operations performed as initial user")
+	} else if config.System.Private {
+		log.Print("server in private mode, all operations require authentication")
+	}
 
-	// Register API and web handlers
-	registerAPI()
-	registerWebpage()
+	webSetup()
 
-	// Signal handling
-	signalChannel := make(chan os.Signal, 1)
-	signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, os.Interrupt, os.Kill)
+	sig := make(chan os.Signal, 1)
+	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
 	go func() {
-		// Cleanup on function return
-		defer func() { cleanup(false) }()
+		defer func() { cleanup() }()
 		for {
-			currentSignal := <-signalChannel
-			switch currentSignal {
+			s := <-sig
+			switch s {
 			case os.Interrupt:
 				println()
-				log.Info("Gracefully exiting.")
+				log.Print("shutting down")
 				return
 			default:
-				log.Info("Gracefully exiting.")
+				log.Print("shutting down")
 				return
 			}
 		}
 	}()
 
-	// Start server
-	runWebServer()
-
-	// Restart if needed
-	if r {
-		restart()
-	}
-}
-
-func openStore() {
-	path := systemConfig["store"].(string)
-	single := parseBool(systemConfig["single-user"])
-	private := parseBool(systemConfig["private"])
-
-	instance = store.New(path, single, private)
-	if instance == nil {
-		log.Fatalf("Error initializing store.")
-	}
-	log.Infof("Store opened on %s revision %v compat %v.", path, instance.Revision, instance.Compat)
-	info := instance.User(instance.InitialUser)
-	if info.Snowflake == instance.InitialUser {
-		log.Infof("Initial user ID %s secret %s.", info.Snowflake, info.Secret)
-		if instance.UserPasswordValidate(info.Snowflake, "initial") {
-			log.Warnf("Initial user still has the initial password.")
-		}
-	} else {
-		if single {
-			log.Fatal("Instance has no initial user, single user mode unavailable.")
-		}
-	}
-	if single {
-		log.Info("Server running in single user mode, all operations are performed as the initial user.")
-	} else if private {
-		log.Info("Server running in private mode, all operations will require authentication.")
-	}
+	serve()
 }
diff --git a/recover.go b/recover.go
index d84c79a..eafd63e 100644
--- a/recover.go
+++ b/recover.go
@@ -1,8 +1,9 @@
 package main
 
 import (
+	"fmt"
 	"github.com/gin-gonic/gin"
-	log "github.com/sirupsen/logrus"
+	"log"
 	"net/http"
 	"runtime/debug"
 )
@@ -12,11 +13,11 @@ func recovery() gin.HandlerFunc {
 		defer func() {
 			p := recover()
 			if p != nil {
-				log.Errorf("Panic occurred in web server, %s", p)
+				log.Printf("panic in web server %s", p)
 				context.JSON(http.StatusInternalServerError, gin.H{
 					"error": "panic",
 				})
-				log.Error(string(debug.Stack()))
+				fmt.Println(string(debug.Stack()))
 			}
 		}()
 		context.Next()
diff --git a/restart.go b/restart.go
deleted file mode 100644
index 8b2ec79..0000000
--- a/restart.go
+++ /dev/null
@@ -1,23 +0,0 @@
-// +build !windows
-
-package main
-
-import (
-	log "github.com/sirupsen/logrus"
-	"os"
-	"syscall"
-)
-
-func restart() {
-	var err error
-
-	if _, err = os.Stat(executable); err != nil {
-		log.Fatalf("Error stat executable path, %s", err)
-	}
-
-	log.Infof("Re-executing %s...", executable)
-	err = syscall.Exec(executable, os.Args, os.Environ())
-	if err != nil {
-		log.Fatalf("Error re-executing, %s", err)
-	}
-}
diff --git a/restart_windows.go b/restart_windows.go
deleted file mode 100644
index 59239fb..0000000
--- a/restart_windows.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package main
-
-import (
-	log "github.com/sirupsen/logrus"
-	"os"
-)
-
-func restart() {
-	if _, err := os.Stat(executable); err != nil {
-		log.Fatalf("Error getting executable path, %s", err)
-	}
-	log.Infof("Program found at %s.", executable)
-	wd, err := os.Getwd()
-	if err != nil {
-		log.Fatalf("Error getting working directory, %s", err)
-	}
-	log.Infof("Current working directory is %s.", wd)
-	_, err = os.StartProcess(executable, []string{}, &os.ProcAttr{
-		Dir:   wd,
-		Env:   nil,
-		Files: []*os.File{os.Stderr, os.Stdin, os.Stdout},
-		Sys:   nil,
-	})
-	if err != nil {
-		log.Fatalf("Error creating new process, %s", err)
-	}
-}
diff --git a/store/flake.go b/store/flake.go
new file mode 100644
index 0000000..53528eb
--- /dev/null
+++ b/store/flake.go
@@ -0,0 +1,59 @@
+package store
+
+import (
+	"github.com/bwmarrin/snowflake"
+	"log"
+	"math/rand"
+)
+
+const (
+	ImageNode = iota
+	UserNode
+	PostNode
+	EventNode
+)
+
+var flakeNodes [4][256]*snowflake.Node
+
+func init() {
+	snowflake.Epoch = 0
+
+	for i := 0; i < 256; i++ {
+		offset := i * 4
+
+		if n, err := snowflake.NewNode(int64(offset + ImageNode)); err != nil {
+			log.Fatalf("error creating image snowflake node, %s", err)
+		} else {
+			flakeNodes[ImageNode][i] = n
+		}
+		if n, err := snowflake.NewNode(int64(offset + UserNode)); err != nil {
+			log.Fatalf("error creating user snowflake node, %s", err)
+		} else {
+			flakeNodes[UserNode][i] = n
+		}
+		if n, err := snowflake.NewNode(int64(offset + PostNode)); err != nil {
+			log.Fatalf("error creating post snowflake node, %s", err)
+		} else {
+			flakeNodes[PostNode][i] = n
+		}
+		if n, err := snowflake.NewNode(int64(offset + EventNode)); err != nil {
+			log.Fatalf("error creating event snowflake node, %s", err)
+		} else {
+			flakeNodes[EventNode][i] = n
+		}
+	}
+}
+
+// MakeFlake makes a flake of specified type.
+func MakeFlake(t int) snowflake.ID {
+	return flakeNodes[t][rand.Intn(256)].Generate()
+}
+
+// FlakeType returns the type of the snowflake.
+func FlakeType(flake string) (int, error) {
+	if id, err := snowflake.ParseString(flake); err != nil {
+		return 0, err
+	} else {
+		return int(id.Node() % 4), nil
+	}
+}
diff --git a/store/image.go b/store/image.go
deleted file mode 100644
index 4bd360f..0000000
--- a/store/image.go
+++ /dev/null
@@ -1,514 +0,0 @@
-package store
-
-import (
-	"bytes"
-	"crypto/sha256"
-	"encoding/json"
-	"fmt"
-	"github.com/nfnt/resize"
-	log "github.com/sirupsen/logrus"
-	"image"
-	_ "image/gif"
-	"image/jpeg"
-	_ "image/png"
-	"os"
-)
-
-const ImageRootPageVariant = "root"
-
-// Image represents metadata of an image.
-type Image struct {
-	Snowflake             string `json:"snowflake"`
-	Hash                  string `json:"hash"`
-	Type                  string `json:"type"`
-	User                  string `json:"user"`
-	Source                string `json:"source"`
-	Parent                string `json:"parent"`
-	Child                 string `json:"child"`
-	Commentary            string `json:"commentary"`
-	CommentaryTranslation string `json:"commentary_translation"`
-}
-
-// MakePreview compresses an image.Image to preview-size.
-func MakePreview(img image.Image) image.Image {
-	return resize.Thumbnail(256, 256, img, resize.Bilinear)
-}
-
-// Images returns a slice of image hashes.
-func (s *Store) Images() []string {
-	var images []string
-	if entries, err := os.ReadDir(s.ImagesDir()); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading first level image directory, %s", err))
-	} else {
-		for _, entry := range entries {
-			if entry.IsDir() {
-				var subEntries []os.DirEntry
-				if subEntries, err = os.ReadDir(s.ImagesDir() + "/" + entry.Name()); err != nil {
-					s.fatalClose(fmt.Sprintf("Error reading second level image directory %s, %s", entry.Name(), err))
-				} else {
-					for _, subEntry := range subEntries {
-						images = append(images, entry.Name()+subEntry.Name())
-					}
-				}
-			}
-		}
-	}
-	return images
-}
-
-// Image returns an image with specific hash.
-func (s *Store) Image(hash string) Image {
-	if !sha256Regex.MatchString(hash) || !s.file(s.ImageMetadataPath(hash)) {
-		return Image{}
-	}
-
-	s.getLock(hash).RLock()
-	defer s.getLock(hash).RUnlock()
-	return s.ImageMetadataRead(s.ImageMetadataPath(hash))
-}
-
-// ImageMetadataRead reads an image metadata file.
-func (s *Store) ImageMetadataRead(path string) Image {
-	var metadata Image
-	if payload, err := os.ReadFile(path); err != nil {
-		if os.IsNotExist(err) {
-			return Image{}
-		}
-		s.fatalClose(fmt.Sprintf("Error reading image metadata %s, %s", path, err))
-	} else {
-		if err = json.Unmarshal(payload, &metadata); err != nil {
-			s.fatalClose(fmt.Sprintf("Error parsing image metadata %s, %s", path, err))
-		}
-	}
-	return metadata
-}
-
-// ImageData returns an image and its data with a specific hash.
-func (s *Store) ImageData(hash string, preview bool) (Image, []byte) {
-	if !sha256Regex.MatchString(hash) || !s.file(s.ImageMetadataPath(hash)) {
-		return Image{}, nil
-	}
-
-	s.getLock(hash).RLock()
-	defer s.getLock(hash).RUnlock()
-	var metadata Image
-	if payload, err := os.ReadFile(s.ImageMetadataPath(hash)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading image %s metadata, %s", hash, err))
-	} else {
-		if err = json.Unmarshal(payload, &metadata); err != nil {
-			s.fatalClose(fmt.Sprintf("Error parsing image %s metadata, %s", hash, err))
-		}
-	}
-	var path string
-	if !preview {
-		path = s.ImageFilePath(hash)
-	} else {
-		path = s.ImagePreviewFilePath(hash)
-	}
-	if data, err := os.ReadFile(path); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading image %s file, %s", hash, err))
-	} else {
-		return metadata, data
-	}
-	return Image{}, nil
-}
-
-// ImageTags returns tags of an image with specific hash.
-func (s *Store) ImageTags(flake string) []string {
-	if !numerical(flake) || !s.dir(s.ImageTagsPath(flake)) {
-		return nil
-	}
-
-	s.getLock(flake).RLock()
-	defer s.getLock(flake).RUnlock()
-	return s.imageTags(flake)
-}
-
-func (s *Store) imageTags(flake string) []string {
-	var tags []string
-	if entries, err := os.ReadDir(s.ImageTagsPath(flake)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading tags of image %s, %s", flake, err))
-	} else {
-		for _, entry := range entries {
-			tags = append(tags, entry.Name())
-		}
-	}
-	return tags
-}
-
-// ImageHasTag figures out if an image has a tag.
-func (s *Store) ImageHasTag(flake, tag string) bool {
-	if !numerical(flake) || !nameRegex.MatchString(tag) {
-		return false
-	}
-	return s.file(s.ImageTagsPath(flake) + "/" + tag)
-}
-
-//ImageSearch searches for images with specific tags.
-func (s *Store) ImageSearch(tags []string) []string {
-	if len(tags) < 1 || tags == nil {
-		return nil
-	}
-
-	// Check if every tag matches name regex and exists
-	for _, tag := range tags {
-		if !nameRegex.MatchString(tag) || !s.file(s.TagPath(tag)) {
-			return nil
-		}
-	}
-
-	// Return if there's only one tag to search for
-	if len(tags) == 1 {
-		return s.Tag(tags[0])
-	}
-
-	// Find entry with the least pages
-	entry := struct {
-		min   int
-		index int
-	}{
-		min:   s.PageTotal("tag_" + tags[0]),
-		index: 0,
-	}
-	for i := 1; i < len(tags); i++ {
-		if entry.min <= 1 {
-			break
-		}
-
-		pages := s.PageTotal("tag_" + tags[i])
-		if pages < entry.min {
-			entry.min = pages
-			entry.index = i
-		}
-	}
-
-	// Get initial tag
-	initial := s.Tag(tags[entry.index])
-
-	// Result slice
-	var result []string
-
-	// Walk flakes from initial tag
-	for _, flake := range initial {
-		match := true
-		// Walk all remaining tags
-		for i, tag := range tags {
-			// Skip the entrypoint entry
-			if i == entry.index {
-				continue
-			}
-
-			// Check if match
-			if !s.ImageHasTag(flake, tag) {
-				match = false
-				break
-			}
-		}
-
-		// Append flake if all tags matched
-		if match {
-			result = append(result, flake)
-		}
-	}
-
-	return result
-}
-
-// ImageAdd adds an image to the store.
-func (s *Store) ImageAdd(data []byte, flake string) Image {
-	if !numerical(flake) || !s.dir(s.UserPath(flake)) {
-		return Image{}
-	}
-
-	// Create image info and set time
-	info := Image{Snowflake: imageNode.Generate().String(), User: flake}
-
-	// Calculate sha256 and convert to string
-	info.Hash = fmt.Sprintf("%x", sha256.Sum256(data))
-
-	// Return existing image if already exists.
-	e := s.Image(info.Hash)
-	if e.Hash == info.Hash {
-		return e
-	}
-
-	s.getLock(info.Hash).Lock()
-	defer s.getLock(info.Hash).Unlock()
-
-	// Decode image and set format
-	var img image.Image
-	if i, format, err := image.Decode(bytes.NewReader(data)); err != nil {
-		log.Warnf("Error decoding upload %s, %s", info.Hash, err)
-		return Image{}
-	} else {
-		img = MakePreview(i)
-		info.Type = format
-	}
-
-	// Create image directory and tags
-	if err := os.MkdirAll(s.ImageHashTagsPath(info.Hash), s.PermissionDir); err != nil {
-		s.fatalClose(fmt.Sprintf("Error creating image %s directory, %s", info.Hash, err))
-	}
-
-	// Generate and save image metadata
-	if payload, err := json.Marshal(info); err != nil {
-		s.fatalClose(fmt.Sprintf("Error encoding metadata of image %s, %s", info.Hash, err))
-	} else {
-		if err = os.WriteFile(s.ImageMetadataPath(info.Hash), payload, s.PermissionFile); err != nil {
-			s.fatalClose(fmt.Sprintf("Error saving metadata of image %s, %s", info.Hash, err))
-		}
-	}
-
-	// Save image
-	if err := os.WriteFile(s.ImageFilePath(info.Hash), data, s.PermissionFile); err != nil {
-		s.fatalClose(fmt.Sprintf("Error saving image %s, %s", info.Hash, err))
-	}
-
-	// Save preview image
-	if preview, err := os.Create(s.ImagePreviewFilePath(info.Hash)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error creating image %s preview, %s", info.Hash, err))
-	} else {
-		if err = jpeg.Encode(preview, img, &jpeg.Options{Quality: 100}); err != nil {
-			s.fatalClose(fmt.Sprintf("Error saving image %s preview, %s", info.Hash, err))
-		}
-		if err = preview.Close(); err != nil {
-			s.fatalClose(fmt.Sprintf("Error closing image %s preview, %s", info.Hash, err))
-		}
-	}
-
-	// Symbolically link image directory to snowflake directory
-	s.link("../images/"+s.ImageHashSplit(info.Hash), s.ImageSnowflakePath(info.Snowflake))
-	s.link("../../../images/"+s.ImageHashSplit(info.Hash), s.UserImagesPath(flake)+"/"+info.Snowflake)
-
-	// Insert image into page index
-	go s.PageInsert(ImageRootPageVariant, info.Snowflake)
-
-	log.Infof("Image hash %s snowflake %s type %s added by user %s.", info.Hash, info.Snowflake, info.Type, info.User)
-	return info
-}
-
-// ImageUpdate updates image metadata.
-func (s *Store) ImageUpdate(hash, source, parent, commentary, commentaryTranslation string) {
-	// Only accept URLs and below 1024 in length
-	if len(source) >= 1024 {
-		return
-	}
-
-	// Only accept commentary and translations below 65536 in length
-	if len(commentary) >= 65536 || len(commentaryTranslation) >= 65536 {
-		return
-	}
-
-	// Get info
-	info := s.Image(hash)
-	if info.Hash != hash {
-		return
-	}
-
-	s.getLock(hash).Lock()
-	defer s.getLock(hash).Unlock()
-
-	var msg string
-
-	// Update and save
-	if source != "\000" && s.MatchURL(source) {
-		info.Source = source
-		msg += "source"
-	}
-
-	if parent != "\000" && parent != info.Snowflake {
-		if p := s.ImageSnowflake(parent); p.Snowflake == parent {
-			// If no parent, then get the current parent and unset
-			if parent == "" {
-				p = s.ImageSnowflake(info.Parent)
-				// If no current parent, nothing to do
-				if p.Snowflake == "" {
-					goto end
-				}
-			} else {
-				// If setting parent but parent has child, reject
-				if p.Child != "" {
-					goto end
-				}
-			}
-
-			info.Parent = parent
-
-			// Update the parent to reflect the child
-			p.Child = info.Snowflake
-			if parent == "" {
-				p.Child = ""
-			}
-			s.getLock(p.Hash).Lock()
-			s.imageMetadataWrite(p)
-			s.getLock(p.Hash).Unlock()
-
-			if msg != "" {
-				msg += ", "
-			}
-			msg += "parent " + parent
-		}
-	end:
-	}
-	if commentary != "\000" {
-		info.Commentary = commentary
-
-		if msg != "" {
-			msg += ", "
-		}
-		msg += "commentary"
-	}
-	if commentaryTranslation != "\000" {
-		info.CommentaryTranslation = commentaryTranslation
-
-		if msg != "" {
-			msg += ", "
-		}
-		msg += "commentary translation"
-	}
-
-	if msg != "" {
-		s.imageMetadataWrite(info)
-		log.Infof("Image %s %s updated.", info.Snowflake, msg)
-	}
-}
-
-func (s *Store) imageMetadataWrite(info Image) {
-	if payload, err := json.Marshal(info); err != nil {
-		s.fatalClose(fmt.Sprintf("Error encoding metadata of image %s, %s", info.Hash, err))
-	} else {
-		if err = os.WriteFile(s.ImageMetadataPath(info.Hash), payload, s.PermissionFile); err != nil {
-			s.fatalClose(fmt.Sprintf("Error saving metadata of image %s, %s", info.Hash, err))
-		}
-	}
-}
-
-// ImageSnowflakes returns a slice of image snowflakes.
-func (s *Store) ImageSnowflakes() []string {
-	var snowflakes []string
-	if entries, err := os.ReadDir(s.ImagesSnowflakeDir()); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading snowflakes, %s", err))
-	} else {
-		for _, entry := range entries {
-			snowflakes = append(snowflakes, entry.Name())
-		}
-	}
-	return snowflakes
-}
-
-// ImageSnowflakeHash returns image hash from snowflake.
-func (s *Store) ImageSnowflakeHash(flake string) string {
-	if !numerical(flake) {
-		return ""
-	}
-
-	if !s.Compat {
-		return s.ImageMetadataRead(s.ImageSnowflakePath(flake) + "/" + infoJson).Hash
-	} else {
-		if path, err := os.ReadFile(s.ImageSnowflakePath(flake)); err != nil {
-			if os.IsNotExist(err) {
-				return ""
-			}
-			s.fatalClose(fmt.Sprintf("Error reading snowflake %s association file, %s", flake, err))
-		} else {
-			return s.ImageMetadataRead(string(path) + "/" + infoJson).Hash
-		}
-	}
-	return ""
-}
-
-// ImageSnowflake returns image that has specific snowflake.
-func (s *Store) ImageSnowflake(flake string) Image {
-	return s.Image(s.ImageSnowflakeHash(flake))
-}
-
-// ImageDestroy destroys an image.
-func (s *Store) ImageDestroy(hash string) {
-	if !sha256Regex.MatchString(hash) || !s.dir(s.ImagePath(hash)) {
-		return
-	}
-
-	// Attempt to disassociate parent
-	s.ImageUpdate(hash, "\000", "", "\000", "\000")
-
-	s.getLock(hash).Lock()
-	defer s.getLock(hash).Unlock()
-
-	info := s.ImageMetadataRead(s.ImageMetadataPath(hash))
-
-	// Disassociate child if set
-	if info.Child != "" {
-		if child := s.ImageSnowflake(info.Child); child.Snowflake == info.Child {
-			s.ImageUpdate(child.Hash, "\000", "", "\000", "\000")
-		}
-	}
-
-	// Untag the image completely
-	tags := s.imageTags(info.Snowflake)
-	for _, tag := range tags {
-		s.imageTagRemove(info.Snowflake, tag)
-	}
-
-	// Remove snowflake
-	if err := os.Remove(s.ImageSnowflakePath(info.Snowflake)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error destroying snowflake %s of image %s, %s", info.Snowflake, hash, err))
-	}
-
-	// Disassociate user
-	if err := os.Remove(s.UserImagesPath(info.User) + "/" + info.Snowflake); err != nil {
-		s.fatalClose(fmt.Sprintf("Error destroying association %s with user %s, %s.", info.Snowflake, info.User, err))
-	}
-
-	// Remove data directory
-	if err := os.RemoveAll(s.ImagePath(hash)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error destroying image %s, %s", hash, err))
-	}
-
-	// Register remove counter
-	s.PageRegisterRemove(ImageRootPageVariant, info.Snowflake)
-
-	log.Infof("Image hash %s snowflake %s destroyed.", info.Hash, info.Snowflake)
-}
-
-// ImageTagAdd adds a tag to an image with specific snowflake.
-func (s *Store) ImageTagAdd(flake, tag string) {
-	if !nameRegex.MatchString(tag) || !numerical(flake) || !s.dir(s.ImageTagsPath(flake)) || !s.dir(s.TagPath(tag)) ||
-		s.file(s.TagPath(tag)+"/"+flake) {
-		return
-	}
-
-	s.getLock(flake).Lock()
-	defer s.getLock(flake).Unlock()
-
-	s.link("../../snowflakes/"+flake, s.TagPath(tag)+"/"+flake)
-	s.link("../../../../tags/"+tag, s.ImageSnowflakePath(flake)+"/tags/"+tag)
-	s.PageInsert("tag_"+tag, flake)
-	log.Infof("Image snowflake %s tagged with %s.", flake, tag)
-}
-
-// ImageTagRemove removes a tag from an image with specific snowflake.
-func (s *Store) ImageTagRemove(flake, tag string) {
-	if !nameRegex.MatchString(tag) || !numerical(flake) || !s.dir(s.ImageTagsPath(flake)) || !s.dir(s.TagPath(tag)) {
-		return
-	}
-
-	s.getLock(flake).Lock()
-	defer s.getLock(flake).Unlock()
-
-	s.imageTagRemove(flake, tag)
-}
-
-func (s *Store) imageTagRemove(flake, tag string) {
-	if s.file(s.ImageTagsPath(flake) + "/" + tag) {
-		if err := os.Remove(s.ImageTagsPath(flake) + "/" + tag); err != nil {
-			s.fatalClose(fmt.Sprintf("Error unreferencing image %s from tag %s, %s", flake, tag, err))
-		}
-	}
-	if s.file(s.TagPath(tag) + "/" + flake) {
-		if err := os.Remove(s.TagPath(tag) + "/" + flake); err != nil {
-			s.fatalClose(fmt.Sprintf("Error unreferencing tag %s from image %s, %s", tag, flake, err))
-		}
-	}
-	s.PageRegisterRemove("tag_"+tag, flake)
-	log.Infof("Image snowflake %s untagged %s.", flake, tag)
-}
diff --git a/store/misc.go b/store/misc.go
deleted file mode 100644
index 38a1c47..0000000
--- a/store/misc.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package store
-
-import (
-	"errors"
-	"net/url"
-	"regexp"
-	"strconv"
-)
-
-const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
-
-var (
-	nameRegex   = regexp.MustCompile(`^[a-z0-9()_-]{3,}$`)
-	sha256Regex = regexp.MustCompile(`\b[A-Fa-f0-9]{64}\b`)
-	secretRegex = regexp.MustCompile(`\b[A-Za-z]{64}\b`)
-)
-
-var (
-	// AlreadyExists is returned when store already exists.
-	AlreadyExists = errors.New("store path already exists")
-)
-
-// numerical validates a numerical string.
-func numerical(flake string) bool {
-	if flake == "" {
-		return false
-	}
-	_, err := strconv.Atoi(flake)
-	return err == nil
-}
-
-// MatchName determines if str is a valid name.
-func MatchName(str string) bool {
-	return nameRegex.MatchString(str)
-}
-
-// MatchURL determines if str is a valid URL.
-func MatchURL(str string) bool {
-	u, err := url.Parse(str)
-	return err == nil && u.Scheme != "" && u.Host != ""
-}
diff --git a/store/page.go b/store/page.go
deleted file mode 100644
index a247e70..0000000
--- a/store/page.go
+++ /dev/null
@@ -1,183 +0,0 @@
-package store
-
-import (
-	"encoding/binary"
-	"fmt"
-	log "github.com/sirupsen/logrus"
-	"github.com/syndtr/goleveldb/leveldb"
-	"os"
-)
-
-const PageSize = 64
-
-// pageDB returns leveldb of page variant and creates it as required.
-func (s *Store) pageDB(variant string) *leveldb.DB {
-	mutex := s.getLock("pageDB_get")
-	mutex.Lock()
-	defer mutex.Unlock()
-
-	if ldb := s.pageldb[variant]; ldb != nil {
-		return ldb
-	} else {
-		if db, err := leveldb.OpenFile(s.PageVariantPath(variant), nil); err != nil {
-			s.fatalClose(fmt.Sprintf("Error opening leveldb for page variant %s, %s", variant, err))
-		} else {
-			s.pageldb[variant] = db
-			if _, err = db.Get([]byte("\000"), nil); err != nil {
-				log.Infof("Page variant %s created.", variant)
-				s.pageSetTotalCountNoDestroy(variant, 0, db)
-			}
-			return db
-		}
-	}
-	return nil
-}
-
-// pageDBDestroy destroys leveldb of page variant.
-func (s *Store) pageDBDestroy(variant string) {
-	if err := s.pageDB(variant).Close(); err != nil {
-		s.fatalClose(fmt.Sprintf("Error closing leveldb for page variant %s, %s", variant, err))
-	} else {
-		delete(s.pageldb, variant)
-	}
-
-	if err := os.RemoveAll(s.PageVariantPath(variant)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error destroying page variant %s, %s", variant, err))
-	}
-
-	log.Infof("Page variant %s destroyed.", variant)
-}
-
-// pageGetTotalCount gets total count of a page variant.
-func (s *Store) pageGetTotalCount(variant string) uint64 {
-	db := s.pageDB(variant)
-
-	if payload, err := db.Get([]byte("\000"), nil); err != nil {
-		s.fatalClose(fmt.Sprintf("Error getting page variant %s total count, %s", variant, err))
-	} else {
-		return binary.LittleEndian.Uint64(payload)
-	}
-
-	return 0
-}
-
-// pageSetTotalCountNoDestroy sets total count of a page variant.
-func (s *Store) pageSetTotalCountNoDestroy(variant string, value uint64, db *leveldb.DB) {
-	payload := make([]byte, 8)
-	binary.LittleEndian.PutUint64(payload, value)
-
-	if err := db.Put([]byte("\000"), payload, nil); err != nil {
-		s.fatalClose(fmt.Sprintf("Error setting page variant %s total count, %s", variant, err))
-	}
-}
-
-// pageSetTotalCount sets total count of a page variant and destroys it if zero.
-func (s *Store) pageSetTotalCount(variant string, value uint64) {
-	if value == 0 {
-		s.pageDBDestroy(variant)
-		return
-	}
-	s.pageSetTotalCountNoDestroy(variant, value, s.pageDB(variant))
-}
-
-// pageAdvanceTotalCount advances total count of a page variant.
-func (s *Store) pageAdvanceTotalCount(variant string) {
-	s.pageSetTotalCount(variant, s.pageGetTotalCount(variant)+1)
-}
-
-// pageReduceTotalCount reduces total count of a page variant.
-func (s *Store) pageReduceTotalCount(variant string) {
-	if total := s.pageGetTotalCount(variant); total == 0 {
-		return
-	} else {
-		s.pageSetTotalCount(variant, total-1)
-	}
-}
-
-// PageTotal returns total amount of pages.
-func (s *Store) PageTotal(variant string) int {
-	totalCount := int(s.pageGetTotalCount(variant))
-	if totalCount == 0 {
-		return 0
-	}
-	return (totalCount / PageSize) + 1
-}
-
-// Page returns all entries in a page.
-func (s *Store) Page(variant string, entry int) []string {
-	if entry >= s.PageTotal(variant) {
-		return nil
-	}
-
-	var page []string
-	start := entry * PageSize
-	end := start + PageSize
-	begin := false
-
-	db := s.pageDB(variant)
-
-	iter := db.NewIterator(nil, nil)
-	i := 0
-	for iter.Next() {
-		if i == end {
-			break
-		}
-		if begin {
-			page = append(page, string(iter.Key()))
-		} else {
-			if i >= start {
-				begin = true
-			}
-		}
-		i++
-	}
-	iter.Release()
-	if err := iter.Error(); err != nil {
-		log.Warnf("Error iterating page variant %s entry %v, %s", variant, entry, err)
-		return nil
-	}
-
-	return page
-}
-
-// PageImages returns all images in a page.
-func (s *Store) PageImages(variant string, entry int) []Image {
-	flakes := s.Page(variant, entry)
-	if flakes == nil {
-		return nil
-	}
-
-	images := make([]Image, len(flakes))
-	for i, flake := range flakes {
-		images[i] = s.ImageSnowflake(flake)
-	}
-	return images
-}
-
-// PageInsert inserts an image into the index.
-func (s *Store) PageInsert(variant, flake string) {
-	if !s.dir(s.ImageSnowflakePath(flake)) {
-		return
-	}
-
-	s.getLock("page_" + variant).Lock()
-	defer s.getLock("page_" + variant).Unlock()
-
-	db := s.pageDB(variant)
-	if err := db.Put([]byte(flake), []byte{}, nil); err != nil {
-		s.fatalClose(fmt.Sprintf("Error inserting image %s into page variant %s, %s", flake, variant, err))
-	}
-	s.pageAdvanceTotalCount(variant)
-}
-
-// PageRegisterRemove registers an image remove.
-func (s *Store) PageRegisterRemove(variant, flake string) {
-	s.getLock("page_" + variant).Lock()
-	defer s.getLock("page_" + variant).Unlock()
-
-	db := s.pageDB(variant)
-	if err := db.Delete([]byte(flake), nil); err != nil {
-		s.fatalClose(fmt.Sprintf("Error removing image %s from page variant %s, %s", flake, variant, err))
-	}
-	s.pageReduceTotalCount(variant)
-}
diff --git a/store/secret.go b/store/secret.go
deleted file mode 100644
index 10d48d2..0000000
--- a/store/secret.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package store
-
-import (
-	"crypto/rand"
-	"fmt"
-	"math/big"
-	"os"
-)
-
-// SecretNew generates a new user secret.
-func (s *Store) SecretNew() string {
-	secret := make([]byte, 64)
-	for i := 0; i < 64; i++ {
-		if n, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))); err != nil {
-			s.fatalClose(fmt.Sprintf("Error generating secret, %s", err))
-		} else {
-			secret[i] = letters[n.Int64()]
-		}
-	}
-	return string(secret)
-}
-
-// SecretLookup looks up a user from a secret.
-func (s *Store) SecretLookup(secret string) User {
-	if !secretRegex.MatchString(secret) || !s.file(s.SecretPath(secret)) {
-		return User{}
-	}
-	if !s.Compat {
-		return s.user(s.SecretPath(secret) + "/" + infoJson)
-	} else {
-		if path, err := os.ReadFile(s.SecretPath(secret)); err != nil {
-			s.fatalClose(fmt.Sprintf("Error reading association file of secret %s, %s", secret, err))
-		} else {
-			return s.user(string(path) + "/" + infoJson)
-		}
-	}
-	return User{}
-}
-
-// SecretAssociate associates a secret with a user.
-func (s *Store) SecretAssociate(secret, flake string) {
-	if s.file(s.SecretPath(secret)) {
-		return
-	}
-	s.link("../users/"+flake, s.SecretPath(secret))
-}
-
-// SecretDisassociate disassociates a secret.
-func (s *Store) SecretDisassociate(secret string) {
-	if !s.file(s.SecretPath(secret)) {
-		return
-	}
-	if err := os.Remove(s.SecretPath(secret)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error disassociating secret %s, %s", secret, err))
-	}
-}
diff --git a/store/spec.go b/store/spec.go
new file mode 100644
index 0000000..effc4ce
--- /dev/null
+++ b/store/spec.go
@@ -0,0 +1,54 @@
+package store
+
+import (
+	"github.com/nfnt/resize"
+	"image"
+	"regexp"
+)
+
+const (
+	// PreviewLimitSize is the maximum amount of pixels of a dimension a preview can have.
+	PreviewLimitSize = 256
+	// ImageRootPageVariant is the name of the root page variant.
+	ImageRootPageVariant = "root"
+	// PageSize is the length of a page.
+	PageSize = 64
+	// MinPasswordLength is the minimum password length in bytes.
+	MinPasswordLength = 8
+	// MaxPasswordLength is the maximum password length in bytes.
+	MaxPasswordLength = 1024
+	// InitialPassword is the default password of the initial user.
+	InitialPassword = "initial_password"
+)
+
+const (
+	// ArtistType is the tag type artist.
+	ArtistType = "artist"
+	// CharacterType is the tag type character.
+	CharacterType = "character"
+	// CopyrightType is the tag type copyright.
+	CopyrightType = "copyright"
+	// MetaType is the tag type meta.
+	MetaType = "meta"
+	// GenericType is the tag type generic.
+	GenericType = "generic"
+)
+
+var allowedTagTypesMap = map[string]bool{
+	ArtistType:    true,
+	CharacterType: true,
+	CopyrightType: true,
+	MetaType:      true,
+	GenericType:   true,
+}
+
+var (
+	nameRegex   = regexp.MustCompile(`^[a-z0-9()_-]{3,}$`)
+	sha256Regex = regexp.MustCompile(`\b[A-Fa-f0-9]{64}\b`)
+	secretRegex = regexp.MustCompile(`\b[A-Za-z]{64}\b`)
+)
+
+// MakePreview compresses an image.Image to preview-size.
+func MakePreview(img image.Image) image.Image {
+	return resize.Thumbnail(PreviewLimitSize, PreviewLimitSize, img, resize.Bilinear)
+}
diff --git a/store/store.go b/store/store.go
index bf359da..899bea0 100644
--- a/store/store.go
+++ b/store/store.go
@@ -1,318 +1,105 @@
 package store
 
 import (
-	"encoding/json"
-	"fmt"
-	"github.com/bwmarrin/snowflake"
-	log "github.com/sirupsen/logrus"
-	"github.com/syndtr/goleveldb/leveldb"
-	"os"
-	"runtime"
-	"strconv"
-	"sync"
-)
-
-const revision = 1
-
-const (
-	UserSnowflakeNodeID  = 7
-	ImageSnowflakeNodeID = 9
+	"errors"
 )
 
 var (
-	imageNode *snowflake.Node
-	userNode  *snowflake.Node
+	// ErrNotDirectory is returned when target path is not a directory.
+	ErrNotDirectory = errors.New("not a directory")
+	// ErrAlreadyExists is returned when target path already exists.
+	ErrAlreadyExists = errors.New("already exists")
+	// ErrNoEntry is returned when the entry does not exist.
+	ErrNoEntry = errors.New("no entry")
+	// ErrInvalidInput is returned when input is invalid.
+	ErrInvalidInput = errors.New("invalid input")
 )
 
-// Info represents system information of a store.
-type Info struct {
-	Revision       int         `json:"revision"`
-	Compat         bool        `json:"compat"`
-	Register       bool        `json:"register"`
-	InitialUser    string      `json:"initial_user"`
-	PermissionDir  os.FileMode `json:"permission_dir"`
-	PermissionFile os.FileMode `json:"permission_file"`
-}
-
-// Store represents a file store.
-type Store struct {
-	Path           string
-	SingleUser     bool
-	Private        bool
-	Revision       int
-	Compat         bool
-	Register       bool
-	InitialUser    string
-	PermissionDir  os.FileMode
-	PermissionFile os.FileMode
-	pageldb        map[string]*leveldb.DB
-	mutex          map[string]*sync.RWMutex
-	sync.RWMutex
-}
-
-func init() {
-	// Set Epoch to beginning of time (01/01/1970)
-	snowflake.Epoch = 0
-
-	// Create snowflake nodes
-	var err error
-	userNode, err = snowflake.NewNode(UserSnowflakeNodeID)
-	if err != nil {
-		log.Fatalf("Error creating user snowflake node, %s", err)
-	}
-	imageNode, err = snowflake.NewNode(ImageSnowflakeNodeID)
-	if err != nil {
-		log.Fatalf("Error creating image snowflake node, %s", err)
-	}
-}
-
-// New initialises a new store instance.
-func New(path string, single, private bool) *Store {
-	var store *Store
-	if stat, err := os.Stat(path); err != nil {
-		log.Infof("Initializing new store at %s.", path)
-		// Initialise empty store.
-		store = &Store{
-			Path:           path,
-			SingleUser:     single,
-			Private:        private,
-			Revision:       revision,
-			Compat:         runtime.GOOS == "windows",
-			Register:       false,
-			PermissionDir:  0700,
-			PermissionFile: 0600,
-			pageldb:        make(map[string]*leveldb.DB),
-			mutex:          make(map[string]*sync.RWMutex),
-		}
-		if err = store.create(); err != nil {
-			log.Fatalf("Error creating store, %s", err)
-		}
-	} else {
-		// Exit if store is not a directory.
-		if !stat.IsDir() {
-			log.Fatal("Store is not a directory.")
-		}
-
-		// Load and parse store info.
-		var info Info
-		var payload []byte
-		if payload, err = os.ReadFile(path + "/" + infoJson); err != nil {
-			log.Fatalf("Error reading store information, %s", err)
-		} else {
-			if err = json.Unmarshal(payload, &info); err != nil {
-				log.Fatalf("Error parsing store information, %s", err)
-			}
-		}
-		if info.Revision != revision {
-			log.Fatalf("Store format revision %v, expecting revision %v.", info.Revision, revision)
-		}
-
-		// Create store instance.
-		store = &Store{
-			Path:           path,
-			SingleUser:     single,
-			Private:        private,
-			Revision:       info.Revision,
-			Compat:         info.Compat,
-			Register:       info.Register,
-			InitialUser:    info.InitialUser,
-			PermissionDir:  info.PermissionDir,
-			PermissionFile: info.PermissionFile,
-			pageldb:        make(map[string]*leveldb.DB),
-			mutex:          make(map[string]*sync.RWMutex),
-		}
-	}
-
-	// Handle store locking
-	if store.file(store.LockPath()) {
-		if pid, err := os.ReadFile(store.LockPath()); err != nil {
-			log.Fatalf("Store locked, lock file unreadable, %s", err)
-		} else {
-			log.Fatalf("Store locked by process %s.", string(pid))
-		}
-	}
-	if err := os.WriteFile(store.LockPath(), []byte(strconv.Itoa(os.Getpid())), store.PermissionFile); err != nil {
-		log.Fatalf("Error locking store, %s", err)
-	}
-
-	return store
-}
-
-// Close closes the store.
-func (s *Store) Close() {
-	// Close all leveldb
-	for variant, ldb := range s.pageldb {
-		if err := ldb.Close(); err != nil {
-			log.Errorf("Error closing leveldb variant %s, %s", variant, err)
-		}
-		log.Infof("Page variant %s closed.", variant)
-	}
-
-	// Unlock store
-	if err := os.Remove(s.LockPath()); err != nil {
-		log.Errorf("Error unlocking store, %s", err)
-	}
-
-	log.Info("Store closed.")
-}
-
-// fatalClose closes the store and as cleanly as possible and exits with a fatal message.
-func (s *Store) fatalClose(message string) {
-	s.Close()
-	log.Fatal(message)
-}
-
-// create sets up the store directory if it does not exist.
-func (s *Store) create() error {
-	// Check if exists
-	if _, err := os.Stat(s.Path); err == nil {
-		return AlreadyExists
-	}
-
-	// Create directories
-	if err := os.Mkdir(s.Path, s.PermissionDir); err != nil {
-		return err
-	}
-	if err := os.Mkdir(s.TagsDir(), s.PermissionDir); err != nil {
-		return err
-	}
-	if err := os.Mkdir(s.ImagesDir(), s.PermissionDir); err != nil {
-		return err
-	}
-	if err := os.Mkdir(s.ImagesSnowflakeDir(), s.PermissionDir); err != nil {
-		return err
-	}
-	if err := os.Mkdir(s.UsersDir(), s.PermissionDir); err != nil {
-		return err
-	}
-	if err := os.Mkdir(s.SecretsDir(), s.PermissionDir); err != nil {
-		return err
-	}
-	if err := os.Mkdir(s.UsernamesDir(), s.PermissionDir); err != nil {
-		return err
-	}
-	if err := os.Mkdir(s.PageBaseDir(), s.PermissionDir); err != nil {
-		return err
-	}
-
-	// Create initial user
-	info := s.UserAdd("root", "initial", true)
-	if info.Snowflake == "" {
-		log.Fatal("Error creating initial user.")
-	} else {
-		log.Infof("Created initial user with username root and password initial.")
-	}
-	s.InitialUser = info.Snowflake
-
-	// Create information file
-	if payload, err := json.Marshal(Info{
-		Revision:       s.Revision,
-		Compat:         s.Compat,
-		Register:       s.Register,
-		InitialUser:    s.InitialUser,
-		PermissionDir:  s.PermissionDir,
-		PermissionFile: s.PermissionFile,
-	}); err != nil {
-		return err
-	} else {
-		if err = os.WriteFile(s.Path+"/"+infoJson, payload, s.PermissionFile); err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-// getLock returns a lock associated with a string.
-func (s *Store) getLock(entry string) *sync.RWMutex {
-	s.RLock()
-	mutex, ok := s.mutex[entry]
-	s.RUnlock()
-	if !ok {
-		s.Lock()
-		mutex = &sync.RWMutex{}
-		s.mutex[entry] = mutex
-		s.Unlock()
-	}
-	return mutex
-}
-
-// file probes for the existence of specified entry on the filesystem.
-func (s *Store) file(path string) bool {
-	if _, err := os.Stat(path); err != nil {
-		if os.IsNotExist(err) {
-			return false
-		} else {
-			s.fatalClose(fmt.Sprintf("Error stat %s, %s", path, err))
-		}
-	}
-	return true
-}
-
-// dir probes for the presence of specified directory on the filesystem.
-func (s *Store) dir(path string) bool {
-	if stat, err := os.Stat(path); err != nil {
-		if os.IsNotExist(err) {
-			return false
-		} else {
-			s.fatalClose(fmt.Sprintf("Error stat %s, %s", path, err))
-		}
-	} else {
-		if !stat.IsDir() {
-			s.fatalClose(fmt.Sprintf("Path %s is not a directory.", path))
-		}
-	}
-	return true
-}
-
-// link provides symlink-like usage while considering compat mode.
-func (s *Store) link(old, new string) {
-	if !s.Compat {
-		if err := os.Symlink(old, new); err != nil {
-			s.fatalClose(fmt.Sprintf("Error linking %s to %s, %s", old, new, err))
-		}
-	} else {
-		if err := os.WriteFile(new, []byte(old), s.PermissionFile); err != nil {
-			s.fatalClose(fmt.Sprintf("Error writing associate file for %s to %s, %s", old, new, err))
-		}
-	}
-}
-
-// readlink provides readlink-like usage while considering compat mode.
-func (s *Store) readlink(path string) string {
-	if !s.Compat {
-		//if final, err := os.Readlink(path); err != nil {
-		//	if os.IsNotExist(err) {
-		//		return ""
-		//	} else {
-		//		log.Fatalf("Error reading symlink %s, %s.", path, err)
-		//	}
-		//} else {
-		//	return final
-		//}
-		return path
-	} else {
-		if final, err := os.ReadFile(path); err != nil {
-			if os.IsNotExist(err) {
-				return ""
-			} else {
-				s.fatalClose(fmt.Sprintf("Error reading association file %s, %s.", path, err))
-			}
-		} else {
-			return string(final)
-		}
-	}
-	return ""
-}
-
-// TODO: These two really shouldn't be methods... Maybe change that for v2.
-
-// MatchName determines if str is a valid name. As of v1, this just calls MatchName.
-func (s *Store) MatchName(str string) bool {
-	return MatchName(str)
-}
-
-// MatchURL determines if str is a valid URL. As of v1, this just calls MatchURL.
-func (s *Store) MatchURL(str string) bool {
-	return MatchURL(str)
+// Store represents a file store implementation.
+type Store interface {
+	// Open sets up the Backend.
+	Open() error
+	// Close cleans up and closes the Backend.
+	Close() error
+	// Name returns name of the Backend.
+	Name() string
+
+	// Users returns a slice of User snowflakes.
+	Users() ([]string, error)
+	// User returns pointer to User by flake.
+	User(flake string) (*User, error)
+	// UserUsername returns pointer to User by username.
+	UserUsername(username string) (*User, error)
+	// UserInitial returns snowflake of initial User.
+	UserInitial() string
+	// UserAdd adds User and returns pointer to its metadata.
+	UserAdd(username string, password string, privileged bool) (*User, error)
+	// UserPrivileged sets privilege of User.
+	UserPrivileged(flake string, privileged bool) error
+	// UserUsernameUpdate updates the username of User.
+	UserUsernameUpdate(flake string, username string) error
+	// UserSecretRegen regenerates the secret of User.
+	UserSecretRegen(flake string) (string, error)
+	// UserPasswordValidate validates the password of User.
+	UserPasswordValidate(flake, username *string, password string) (bool, error)
+	// UserPasswordUpdate updates the password of User.
+	UserPasswordUpdate(flake string, password string) error
+	// UserImages return a slice of image snowflakes owned by User.
+	UserImages(flake string) ([]string, error)
+	// UserImage validates whether a user owns an Image.
+	UserImage(flake, imageFlake string) (bool, error)
+	// UserDestroy destroys User.
+	UserDestroy(flake string) error
+
+	// SecretLookup looks up the corresponding User of a secret.
+	SecretLookup(secret string) (*User, error)
+	// SecretValidate validates the validity of a secret.
+	SecretValidate(secret string) bool
+
+	// Images returns a slice of Image snowflakes.
+	Images() ([]string, error)
+	// Image returns pointer to Image by flake.
+	Image(flake string) (*Image, error)
+	// ImageAdd adds an Image and returns pointer to Image.
+	ImageAdd(data []byte, flake string) (*Image, error)
+	// ImageTags return a slice of tags of Image.
+	ImageTags(flake string) ([]string, error)
+	// ImageHasTag returns whether Image has a tag.
+	ImageHasTag(flake string, tag string) (bool, error)
+	// ImageSearch searches for images that contains slice of tags passed.
+	ImageSearch(tags []string) ([]string, error)
+	// ImageUpdate updates Image.
+	ImageUpdate(flake string, source string, parent string, commentary string, commentaryTranslation string) error
+	// ImageDestroy destroys Image.
+	ImageDestroy(flake string) error
+	// ImageTagAdd adds a tag to Image.
+	ImageTagAdd(flake string, tag string) error
+	// ImageTagRemove removes a tag from Image.
+	ImageTagRemove(flake string, tag string) error
+	// ImageHashes return a slice of Image hashes.
+	ImageHashes() ([]string, error)
+	// ImageHash returns pointer to Image by hash.
+	ImageHash(hash string) (*Image, error)
+	// ImageData returns data of an Image by hash.
+	ImageData(hash string, preview bool) (*Image, []byte, error)
+	// ImageSnowflakeHash returns snowflake of Image by hash.
+	ImageSnowflakeHash(flake string) (string, error)
+
+	// Tags returns a slice of Tag.
+	Tags() ([]string, error)
+	// Tag returns pointer to Tag.
+	Tag(tag string) (*Tag, error)
+	// TagImages returns a slice of Image flakes with Tag.
+	TagImages(tag string) ([]string, error)
+	// TagAdd adds a Tag.
+	TagAdd(tag string) error
+	// TagDestroy destroys a Tag.
+	TagDestroy(tag string) error
+	// TagType sets type of Tag.
+	TagType(tag string, t string) error
+
+	// PageTotal returns total amount of pages of variant.
+	PageTotal(variant string) (uint64, error)
+	// Page returns page content of a specific entry in a page variant.
+	Page(variant string, entry uint64) ([]string, error)
 }
diff --git a/store/store_test.go b/store/store_test.go
new file mode 100644
index 0000000..72440ea
--- /dev/null
+++ b/store/store_test.go
@@ -0,0 +1 @@
+package store
diff --git a/store/structs.go b/store/structs.go
new file mode 100644
index 0000000..78f192e
--- /dev/null
+++ b/store/structs.go
@@ -0,0 +1,52 @@
+package store
+
+import (
+	"crypto/rand"
+	"math/big"
+	"time"
+)
+
+// User represents metadata of a user.
+type User struct {
+	Secret     string `json:"secret"`
+	Privileged bool   `json:"privileged"`
+	Snowflake  string `json:"snowflake"`
+	Username   string `json:"username"`
+}
+
+// Tombstone represents user deletion information.
+type Tombstone struct {
+	Time int `json:"time"`
+}
+
+// Image represents metadata of an image.
+type Image struct {
+	Snowflake             string `json:"snowflake"`
+	Hash                  string `json:"hash"`
+	Type                  string `json:"type"`
+	User                  string `json:"user"`
+	Source                string `json:"source"`
+	Parent                string `json:"parent"`
+	Child                 string `json:"child"`
+	Commentary            string `json:"commentary"`
+	CommentaryTranslation string `json:"commentary_translation"`
+}
+
+// Tag represents metadata of a tag.
+type Tag struct {
+	Type         string    `json:"type"`
+	CreationTime time.Time `json:"creation_time"`
+}
+
+// SecretNew generates a new user secret.
+func SecretNew() (string, error) {
+	secret := make([]byte, 64)
+	for i := 0; i < 64; i++ {
+		if n, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))); err != nil {
+			return "", err
+		} else {
+			secret[i] = letters[n.Int64()]
+		}
+	}
+	return string(secret), nil
+}
diff --git a/store/tag.go b/store/tag.go
deleted file mode 100644
index 24d1f09..0000000
--- a/store/tag.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package store
-
-import (
-	"encoding/json"
-	"fmt"
-	log "github.com/sirupsen/logrus"
-	"os"
-	"strings"
-	"time"
-)
-
-const (
-	// ArtistType is the tag type artist.
-	ArtistType = "artist"
-	// CharacterType is the tag type character.
-	CharacterType = "character"
-	// CopyrightType is the tag type copyright.
-	CopyrightType = "copyright"
-	// MetaType is the tag type meta.
-	MetaType = "meta"
-	// GenericType is the tag type generic.
-	GenericType = "generic"
-)
-
-var (
-	// AllowedTagTypes represent tag type strings that are allowed.
-	AllowedTagTypes    = []string{ArtistType, CharacterType, CopyrightType, MetaType, GenericType}
-	allowedTagTypesMap = map[string]bool{
-		ArtistType:    true,
-		CharacterType: true,
-		CopyrightType: true,
-		MetaType:      true,
-		GenericType:   true,
-	}
-)
-
-// TagTypeAllowed returns whether str is an allowed tag type.
-func TagTypeAllowed(str string) bool {
-	return allowedTagTypesMap[str]
-}
-
-// Tag represents metadata of a tag.
-type Tag struct {
-	Type         string    `json:"type"`
-	CreationTime time.Time `json:"creation_time"`
-}
-
-// Tags returns a slice of tag names.
-func (s *Store) Tags() []string {
-	var tags []string
-	if entries, err := os.ReadDir(s.TagsDir()); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading tags, %s", err))
-	} else {
-		for _, entry := range entries {
-			if entry.IsDir() {
-				tags = append(tags, entry.Name())
-			}
-		}
-	}
-	return tags
-}
-
-// Tag returns a slice of image snowflakes in a specific tag.
-func (s *Store) Tag(tag string) []string {
-	if !nameRegex.MatchString(tag) || !s.dir(s.TagPath(tag)) {
-		return nil
-	}
-	var images []string
-	if entries, err := os.ReadDir(s.TagPath(tag)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading tag %s, %s", tag, err))
-	} else {
-		for _, entry := range entries {
-			if entry.Name() == infoJson {
-				continue
-			}
-			images = append(images, entry.Name())
-		}
-	}
-	return images
-}
-
-// TagCreate creates a tag and returns ok value.
-func (s *Store) TagCreate(tag string) bool {
-	if len(tag) > 128 || !nameRegex.MatchString(tag) {
-		return false
-	}
-	if !s.dir(s.TagPath(tag)) {
-		s.getLock("tag_" + tag).Lock()
-		defer s.getLock("tag_" + tag).Unlock()
-		if err := os.Mkdir(s.TagPath(tag), s.PermissionDir); err != nil {
-			s.fatalClose(fmt.Sprintf("Error creating tag %s, %s", tag, err))
-		}
-		if payload, err := json.Marshal(Tag{Type: GenericType, CreationTime: time.Now().UTC()}); err != nil {
-			s.fatalClose(fmt.Sprintf("Error generating tag %s metadata, %s", tag, err))
-		} else {
-			if err = os.WriteFile(s.TagMetadataPath(tag), payload, s.PermissionFile); err != nil {
-				s.fatalClose(fmt.Sprintf("Error writing tag %s metadata, %s", tag, err))
-			}
-		}
-		log.Infof("Tag %s created.", tag)
-		return true
-	}
-	return true
-}
-
-// TagDestroy removes all references from a tag and removes it.
-func (s *Store) TagDestroy(tag string) {
-	if !nameRegex.MatchString(tag) || !s.dir(s.TagPath(tag)) {
-		return
-	}
-
-	flakes := s.Tag(tag)
-	for _, flake := range flakes {
-		s.ImageTagRemove(flake, tag)
-	}
-	if err := os.Remove(s.TagMetadataPath(tag)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error removing tag %s metadata, %s", tag, err))
-	}
-	if err := os.Remove(s.TagPath(tag)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error removing tag %s, %s", tag, err))
-	}
-	log.Infof("Tag %s destroyed.", tag)
-}
-
-// TagInfo returns information of a tag.
-func (s *Store) TagInfo(tag string) Tag {
-	if !nameRegex.MatchString(tag) || !s.file(s.TagMetadataPath(tag)) {
-		return Tag{}
-	}
-
-	s.getLock("tag_" + tag).RLock()
-	defer s.getLock("tag_" + tag).RUnlock()
-	if payload, err := os.ReadFile(s.TagMetadataPath(tag)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading tag %s metadata, %s", tag, err))
-	} else {
-		var info Tag
-		if err = json.Unmarshal(payload, &info); err != nil {
-			s.fatalClose(fmt.Sprintf("Error parsing tag %s metadata, %s", tag, err))
-		} else {
-			return info
-		}
-	}
-	return Tag{}
-}
-
-// TagType sets type of tag.
-func (s *Store) TagType(tag, t string) {
-	if !nameRegex.MatchString(tag) || !s.file(s.TagMetadataPath(tag)) {
-		return
-	}
-
-	if !TagTypeAllowed(t) {
-		log.Warnf("Invalid tag change on tag %s, got %s, expecting one of %s.", tag, t, strings.Join(AllowedTagTypes, ", "))
-		return
-	}
-	info := s.TagInfo(tag)
-
-	s.getLock("tag_" + tag).Lock()
-	defer s.getLock("tag_" + tag).Unlock()
-
-	info.Type = t
-	if payload, err := json.Marshal(info); err != nil {
-		s.fatalClose(fmt.Sprintf("Error updating tag %s metadata, %s", tag, err))
-	} else {
-		if err = os.WriteFile(s.TagMetadataPath(tag), payload, s.PermissionFile); err != nil {
-			s.fatalClose(fmt.Sprintf("Error writing tag %s metadata, %s", tag, err))
-		}
-	}
-	log.Infof("Tag %s type set to %s.", tag, t)
-}
diff --git a/store/user.go b/store/user.go
deleted file mode 100644
index f8795a2..0000000
--- a/store/user.go
+++ /dev/null
@@ -1,313 +0,0 @@
-package store
-
-import (
-	"encoding/json"
-	"fmt"
-	log "github.com/sirupsen/logrus"
-	"os"
-)
-
-// User represents metadata of a user.
-type User struct {
-	Secret     string `json:"secret"`
-	Privileged bool   `json:"privileged"`
-	Snowflake  string `json:"snowflake"`
-	Username   string `json:"username"`
-}
-
-// user parses user metadata file.
-func (s *Store) user(path string) User {
-	if payload, err := os.ReadFile(path); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading user metadata %s, %s", path, err))
-	} else {
-		var info User
-		if err = json.Unmarshal(payload, &info); err != nil {
-			s.fatalClose(fmt.Sprintf("Error parsing user metadata %s, %s", path, err))
-		} else {
-			return info
-		}
-	}
-	return User{}
-}
-
-// User returns user information with specific snowflake.
-func (s *Store) User(flake string) User {
-	if !numerical(flake) || !s.file(s.UserPath(flake)) {
-		return User{}
-	}
-
-	s.getLock(flake).RLock()
-	defer s.getLock(flake).RUnlock()
-	return s.user(s.UserMetadataPath(flake))
-}
-
-// Users returns a slice of user snowflakes.
-func (s *Store) Users() []string {
-	var users []string
-	if entries, err := os.ReadDir(s.UsersDir()); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading users, %s", err))
-	} else {
-		for _, entry := range entries {
-			if entry.IsDir() {
-				users = append(users, entry.Name())
-			}
-		}
-	}
-	return users
-}
-
-// UserMetadata sets user metadata.
-func (s *Store) UserMetadata(info User) {
-	if payload, err := json.Marshal(info); err != nil {
-		s.fatalClose(fmt.Sprintf("Error updating user %s metadata, %s", info.Snowflake, err))
-	} else {
-		if err = os.WriteFile(s.UserMetadataPath(info.Snowflake), payload, s.PermissionFile); err != nil {
-			s.fatalClose(fmt.Sprintf("Error writing user %s metadata, %s", info.Snowflake, err))
-		}
-	}
-}
-
-// UserAdd creates a user.
-func (s *Store) UserAdd(username, password string, privileged bool) User {
-	if len(username) > 64 || !nameRegex.MatchString(username) || s.file(s.UsernamePath(username)) {
-		return User{}
-	}
-
-	info := User{
-		Secret:     s.SecretNew(),
-		Privileged: privileged,
-		Snowflake:  userNode.Generate().String(),
-		Username:   username,
-	}
-	// Create user directory and images
-	if err := os.MkdirAll(s.UserImagesPath(info.Snowflake), s.PermissionDir); err != nil {
-		s.fatalClose(fmt.Sprintf("Error creating user %s directory, %s", info.Snowflake, err))
-	}
-	s.getLock(info.Snowflake).Lock()
-	defer s.getLock(info.Snowflake).Unlock()
-	// Save user metadata
-	s.UserMetadata(info)
-	// Associate new user secret
-	s.SecretAssociate(info.Secret, info.Snowflake)
-	// Associate username
-	s.userUsernameAssociate(info.Snowflake, info.Username)
-	// Set password
-	s.userPasswordUpdate(s.UserPath(info.Snowflake), password)
-
-	log.Infof("User %s added with username %s privilege %v secret %s.", info.Snowflake, info.Username, info.Privileged, info.Secret)
-	return info
-}
-
-// UserPrivileged sets privileged status of user with specific snowflake.
-func (s *Store) UserPrivileged(flake string, privileged bool) {
-	if !numerical(flake) {
-		return
-	}
-
-	s.getLock(flake).Lock()
-	defer s.getLock(flake).Unlock()
-
-	info := s.user(s.UserMetadataPath(flake))
-	if info.Snowflake != flake {
-		return
-	}
-	info.Privileged = privileged
-	s.UserMetadata(info)
-	log.Infof("User %s privileged %v", flake, privileged)
-}
-
-// UserUsernameUpdate updates username of user with specific snowflake.
-func (s *Store) UserUsernameUpdate(flake, username string) bool {
-	if !numerical(flake) || !nameRegex.MatchString(username) || s.file(s.UsernamePath(username)) {
-		return false
-	}
-
-	s.getLock(flake).Lock()
-	defer s.getLock(flake).Unlock()
-
-	info := s.user(s.UserMetadataPath(flake))
-	if info.Snowflake != flake {
-		return false
-	}
-	// Lock old username
-	s.getLock(info.Username).Lock()
-	defer s.getLock(info.Username).Unlock()
-	// Disassociate old username and associate new
-	if info.Username != "" {
-		s.userUsernameDisassociate(info.Username)
-	}
-	s.userUsernameAssociate(flake, username)
-	// Set username in metadata
-	info.Username = username
-	s.UserMetadata(info)
-
-	log.Infof("User %s username updated to %s.", flake, username)
-	return true
-}
-
-// UserSecretRegen regenerates secret of user with specific snowflake.
-func (s *Store) UserSecretRegen(flake string) string {
-	if !numerical(flake) {
-		return ""
-	}
-
-	s.getLock(flake).Lock()
-	defer s.getLock(flake).Unlock()
-
-	info := s.user(s.UserMetadataPath(flake))
-	// Check if obtained info is valid
-	if info.Snowflake != flake {
-		return ""
-	}
-	// Disassociate old user
-	s.SecretDisassociate(info.Secret)
-	// Generate new secret
-	info.Secret = s.SecretNew()
-	// Write metadata
-	s.UserMetadata(info)
-	// Associate new secret
-	s.SecretAssociate(info.Secret, info.Snowflake)
-	// Log the event
-	log.Infof("User %s secret reset to %s.", flake, info.Secret)
-	return info.Secret
-}
-
-// UserUsername returns user via username.
-func (s *Store) UserUsername(username string) User {
-	if !nameRegex.MatchString(username) || !s.file(s.UsernamePath(username)) {
-		return User{}
-	}
-	s.getLock(username).RLock()
-	user := s.user(s.readlink(s.UsernamePath(username)) + "/" + infoJson)
-	s.getLock(username).RUnlock()
-	return user
-}
-
-// userUsernameAssociate associates user snowflake with specific username.
-func (s *Store) userUsernameAssociate(flake, username string) {
-	s.link("../users/"+flake, s.UsernamePath(username))
-}
-
-// userUsernameDisassociate disassociates specific username.
-func (s *Store) userUsernameDisassociate(username string) {
-	if err := os.Remove(s.UsernamePath(username)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error disassociating username %s, %s", username, err))
-	}
-}
-
-// userPassword returns password of user from path to user directory.
-func (s *Store) userPassword(path string) string {
-	if payload, err := os.ReadFile(path + "/passwd"); err != nil {
-		if os.IsNotExist(err) {
-			return ""
-		}
-		s.fatalClose(fmt.Sprintf("Error reading password from user directory %s, %s", path, err))
-	} else {
-		return string(payload)
-	}
-	return ""
-}
-
-// userPasswordUpdate updates user password of user from path to user directory.
-func (s *Store) userPasswordUpdate(path, password string) bool {
-	if len(password) > 1024 {
-		return false
-	}
-	if err := os.WriteFile(path+"/passwd", []byte(password), s.PermissionFile); err != nil {
-		s.fatalClose(fmt.Sprintf("Error setting password for user directory %s, %s", path, err))
-	} else {
-		return true
-	}
-	return false
-}
-
-// UserPasswordValidate validates password of specified user.
-func (s *Store) UserPasswordValidate(flake, password string) bool {
-	if !numerical(flake) || !s.file(s.UserPath(flake)) {
-		return false
-	}
-
-	s.getLock(flake).RLock()
-	defer s.getLock(flake).RUnlock()
-
-	// Check password is not empty and matches
-	if password != "" && password == s.userPassword(s.UserPath(flake)) {
-		return true
-	}
-
-	return false
-}
-
-// UserUsernamePasswordValidate validates password of specified user from username.
-func (s *Store) UserUsernamePasswordValidate(username, password string) bool {
-	if !nameRegex.MatchString(username) || !s.file(s.UsernamePath(username)) {
-		return false
-	}
-
-	s.getLock(username).RLock()
-	defer s.getLock(username).RUnlock()
-
-	if password != "" && password == s.userPassword(s.readlink(s.UsernamePath(username))) {
-		return true
-	}
-
-	return false
-}
-
-// UserPasswordUpdate updates password of specified user.
-func (s *Store) UserPasswordUpdate(flake, password string) bool {
-	if !numerical(flake) {
-		return false
-	}
-
-	s.getLock(flake).Lock()
-	defer s.getLock(flake).Unlock()
-
-	return s.userPasswordUpdate(s.UserPath(flake), password)
-}
-
-// UserDestroy destroys a user with specific snowflake.
-func (s *Store) UserDestroy(flake string) {
-	if !numerical(flake) {
-		return
-	}
-	if !s.dir(s.UserPath(flake)) {
-		return
-	}
-
-	s.getLock(flake).Lock()
-	defer s.getLock(flake).Unlock()
-
-	info := s.User(flake)
-	// Check if info correct
-	if info.Snowflake != flake {
-		return
-	}
-	// Disassociate secret
-	s.SecretDisassociate(info.Snowflake)
-	// Remove user data directory
-	if err := os.RemoveAll(s.UserPath(flake)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error destroying user %s data directory, %s", flake, err))
-	}
-	log.Infof("User %s username %s destroyed.", info.Snowflake, info.Username)
-}
-
-// UserImages returns slice of a user's images.
-func (s *Store) UserImages(flake string) []string {
-	if !numerical(flake) {
-		return nil
-	}
-	if !s.dir(s.UserImagesPath(flake)) {
-		return nil
-	}
-
-	var images []string
-	if entries, err := os.ReadDir(s.UserImagesPath(flake)); err != nil {
-		s.fatalClose(fmt.Sprintf("Error reading user %s images, %s", flake, err))
-	} else {
-		for _, entry := range entries {
-			images = append(images, entry.Name())
-		}
-	}
-	return images
-}
diff --git a/store/validation.go b/store/validation.go
new file mode 100644
index 0000000..6f78e9e
--- /dev/null
+++ b/store/validation.go
@@ -0,0 +1,57 @@
+package store
+
+import (
+	"net/url"
+	"strconv"
+	"unicode"
+)
+
+const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+
+// Numerical validates whether a string is numerical.
+func Numerical(flake string) bool {
+	if flake == "" {
+		return false
+	}
+	_, err := strconv.Atoi(flake)
+	return err == nil
+}
+
+// MatchName determines if str is a valid name.
+func MatchName(str string) bool {
+	return nameRegex.MatchString(str)
+}
+
+// MatchSha256 determines if str is a sha256 representation.
+func MatchSha256(str string) bool {
+	return sha256Regex.MatchString(str)
+}
+
+// MatchSecret determines if str is a secret.
+func MatchSecret(str string) bool {
+	return secretRegex.MatchString(str)
+}
+
+// MatchURL determines if str is a valid URL.
+func MatchURL(str string) bool {
+	u, err := url.Parse(str)
+	return err == nil && u.Scheme != "" && u.Host != ""
+}
+
+// MatchTagType returns whether str is an allowed tag type.
+func MatchTagType(str string) bool {
+	return allowedTagTypesMap[str]
+}
+
+// MatchPassword returns whether str is a valid password.
+func MatchPassword(str string) bool {
+	if l := len(str); l > MaxPasswordLength || l < MinPasswordLength {
+		return false
+	}
+	for _, r := range str {
+		if r > unicode.MaxASCII {
+			return false
+		}
+	}
+	return true
+}
diff --git a/web.go b/web.go
index 6fc7eeb..508cab3 100644
--- a/web.go
+++ b/web.go
@@ -2,16 +2,13 @@ package main
 
 import (
 	"embed"
-	"fmt"
 	"github.com/gin-gonic/gin"
-	log "github.com/sirupsen/logrus"
-	"github.com/spf13/viper"
 	"io/fs"
+	"log"
 	"net"
 	"net/http"
 	"os"
 	"strconv"
-	"syscall"
 )
 
 //go:embed assets
@@ -20,87 +17,72 @@ var assets embed.FS
 var (
 	router   *gin.Engine
 	listener net.Listener
+	server   = http.Server{}
 )
 
 func webSetup() {
-	// Set log level according to
-	if log.GetLevel() == log.DebugLevel {
+	gin.SetMode(gin.ReleaseMode)
+	if config.System.Verbose {
 		gin.SetMode(gin.DebugMode)
-	} else {
-		gin.SetMode(gin.ReleaseMode)
 	}
 
-	// Configure gin router
 	router = gin.New()
 	router.Use(recovery())
-	router.ForwardedByClientIP = viper.GetStringMap("server")["proxy"].(bool)
-
-	// Configure logger
-	if log.GetLevel() == log.DebugLevel {
-		router.Use(gin.LoggerWithWriter(logger{}))
+	router.ForwardedByClientIP = config.Server.Proxy
+	if config.System.Verbose {
+		router.Use(gin.Logger())
 	}
-
-	// Redirect on no route
 	router.NoRoute(func(context *gin.Context) {
 		context.Redirect(http.StatusTemporaryRedirect, "/web")
 	})
-}
 
-func listenerSetup() {
-	var err error
-	switch serverConfig["unix"].(bool) {
-	case false:
-		address := fmt.Sprintf("%s:%s",
-			serverConfig["host"].(string),
-			strconv.Itoa(int(serverConfig["port"].(int64))))
-		listener, err = net.Listen("tcp", address)
-		if err != nil {
-			log.Errorf("Error binding %s, %s", address, err)
+	if config.Server.Unix {
+		if l, err := net.Listen("unix", config.Server.Host); err != nil {
+			log.Fatalf("error listening on socket: %s", err)
+		} else {
+			listener = l
 		}
-		log.Infof("Web server listening on %s.", address)
-	case true:
-		path := serverConfig["host"].(string)
-		listener, err = net.Listen("unix", path)
-		if err != nil {
-			log.Errorf("Error binding %s, %s", path, err)
+
+		if err := os.Chmod(config.Server.Host, 0777); err != nil {
+			log.Printf("error chmod: %s", err)
 		}
 
-		err = syscall.Chmod(path, 0777)
-		if err != nil {
-			log.Errorf("Error changing permission of web server socket, %s", err)
+		log.Printf("web server listening on socket %s", config.Server.Host)
+	} else {
+		if l, err := net.Listen("tcp", config.Server.Host+":"+strconv.Itoa(int(config.Server.Port))); err != nil {
+			log.Fatalf("error listening on TCP port: %s", err)
+		} else {
+			listener = l
 		}
 
-		log.Infof("Web server listening on unix socket %s.", path)
+		log.Printf("web server listening on %s:%d", config.Server.Host, config.Server.Port)
 	}
-
-	// Configure server
 	server.Handler = router
-}
-
-func runWebServer() {
-	err := server.Serve(listener)
-	if err == http.ErrServerClosed {
-		log.Info("Web server closed.")
-	} else {
-		log.Errorf("Error starting server, %s", err)
-	}
-}
 
-func registerWebpage() {
 	router.GET("/", func(context *gin.Context) {
 		context.Redirect(http.StatusTemporaryRedirect, "web")
 	})
 
 	if stat, err := os.Stat("assets/public"); err == nil && stat.IsDir() {
-		log.Info("Serving web interface from filesystem.")
+		log.Print("serving web assets from filesystem")
 		router.Static("/web", "assets/public")
 	} else {
-		log.Info("Serving bundled assets.")
+		log.Print("serving bundled web assets")
 		var public fs.FS
 		public, err = fs.Sub(assets, "assets/public")
 		if err != nil {
-			log.Fatalf("Error getting subdirectory, %s", err)
+			log.Fatalf("error subdirectory: %s", err)
 		}
 		router.StaticFS("/web", http.FS(public))
 	}
+
+	registerAPI()
+}
+
+func serve() {
+	if err := server.Serve(listener); err == http.ErrServerClosed {
+		log.Printf("web server closed")
+	} else {
+		log.Printf("error serve: %s", err)
+	}
 }
-- 
GitLab