summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRandomChars <random@chars.jp>2021-11-20 09:16:14 +0900
committerRandomChars <random@chars.jp>2021-11-20 09:16:14 +0900
commit08eb8f733413a5b9c3d25328e60746f3019ec7c8 (patch)
tree14dd8327f5a9bd10014117ebaeb2b76e82df093f
parenta33974f5d0d11258d18a3dfe9450e8798e699472 (diff)
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
-rw-r--r--.gitignore7
-rw-r--r--api.go652
-rw-r--r--api/errors.go37
-rw-r--r--api/f.go81
-rw-r--r--api/paths.go32
-rw-r--r--api/types.go21
-rw-r--r--api/v1.go668
-rw-r--r--api/v2.go21
-rw-r--r--backend/filesystem/image.go553
-rw-r--r--backend/filesystem/page.go213
-rw-r--r--backend/filesystem/paths.go (renamed from store/paths.go)49
-rw-r--r--backend/filesystem/secret.go47
-rw-r--r--backend/filesystem/store.go266
-rw-r--r--backend/filesystem/tag.go148
-rw-r--r--backend/filesystem/user.go381
-rw-r--r--cleanup.go21
-rw-r--r--client/image.go195
-rw-r--r--client/js/README1
-rw-r--r--client/js/main.go13
-rw-r--r--client/misc.go7
-rw-r--r--client/remote.go71
-rw-r--r--client/request.go115
-rw-r--r--client/tag.go81
-rw-r--r--client/user.go107
-rw-r--r--config.go146
-rw-r--r--go.mod30
-rw-r--r--go.sum360
-rw-r--r--log.go42
-rw-r--r--main.go123
-rw-r--r--recover.go7
-rw-r--r--restart.go23
-rw-r--r--restart_windows.go27
-rw-r--r--store/flake.go59
-rw-r--r--store/image.go514
-rw-r--r--store/misc.go41
-rw-r--r--store/page.go183
-rw-r--r--store/secret.go56
-rw-r--r--store/spec.go54
-rw-r--r--store/store.go405
-rw-r--r--store/store_test.go1
-rw-r--r--store/structs.go52
-rw-r--r--store/tag.go170
-rw-r--r--store/user.go313
-rw-r--r--store/validation.go57
-rw-r--r--web.go88
45 files changed, 3001 insertions, 3537 deletions
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
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)
+ }
}