From 2e04affb005cd6649a7753f116dea50dc0610679 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sat, 27 Jul 2024 23:29:18 +0800 Subject: [PATCH] supports local file system object storage and use it as the default avatar provider --- Dockerfile | 3 +- cmd/initializer.go | 10 + cmd/webserver.go | 26 +++ conf/ezbookkeeping.ini | 10 +- pkg/api/users.go | 183 ++++++++++++++++++ pkg/core/handler.go | 5 +- pkg/errs/setting.go | 26 +-- pkg/errs/system.go | 13 +- pkg/errs/user.go | 3 + pkg/models/user.go | 5 +- pkg/services/users.go | 19 ++ pkg/settings/setting.go | 42 +++- pkg/storage/local_filesystem_storage.go | 68 +++++++ pkg/storage/object_in_storage.go | 11 ++ pkg/storage/storage.go | 9 + pkg/storage/storage_container.go | 56 ++++++ pkg/utils/avatar.go | 9 + pkg/utils/avatar_test.go | 10 + pkg/utils/io.go | 43 ++++ pkg/utils/io_test.go | 143 ++++++++++++++ src/consts/file.js | 5 + src/lib/services.js | 32 +++ src/locales/en.js | 11 ++ src/locales/zh_Hans.js | 11 ++ src/stores/user.js | 57 +++++- .../settings/tabs/UserBasicSettingTab.vue | 77 +++++++- 26 files changed, 858 insertions(+), 29 deletions(-) create mode 100644 pkg/storage/local_filesystem_storage.go create mode 100644 pkg/storage/object_in_storage.go create mode 100644 pkg/storage/storage.go create mode 100644 pkg/storage/storage_container.go create mode 100644 pkg/utils/io_test.go create mode 100644 src/consts/file.js diff --git a/Dockerfile b/Dockerfile index a99bb3dc..a1f987fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,8 @@ COPY docker/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh RUN mkdir -p /ezbookkeeping && chown 1000:1000 /ezbookkeeping \ && mkdir -p /ezbookkeeping/data && chown 1000:1000 /ezbookkeeping/data \ - && mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log + && mkdir -p /ezbookkeeping/log && chown 1000:1000 /ezbookkeeping/log \ + && mkdir -p /ezbookkeeping/storage && chown 1000:1000 /ezbookkeeping/storage WORKDIR /ezbookkeeping COPY --from=be-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/ezbookkeeping /ezbookkeeping/ezbookkeeping COPY --from=fe-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/dist /ezbookkeeping/public diff --git a/cmd/initializer.go b/cmd/initializer.go index a0298d67..562e43cf 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -12,6 +12,7 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/mail" "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/storage" "github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/uuid" ) @@ -80,6 +81,15 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) { return nil, err } + err = storage.InitializeStorageContainer(config) + + if err != nil { + if !isDisableBootLog { + log.BootErrorf("[initializer.initializeSystem] initializes object storage failed, because %s", err.Error()) + } + return nil, err + } + err = uuid.InitializeUuidGenerator(config) if err != nil { diff --git a/cmd/webserver.go b/cmd/webserver.go index 2434b0a9..9942fb8b 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -145,6 +145,14 @@ func startWebServer(c *cli.Context) error { router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i])) } + if config.AvatarProvider == settings.InternalAvatarProvider { + avatarRoute := router.Group("/avatar") + avatarRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString)) + { + avatarRoute.GET("/:fileName", bindImage(api.Users.UserGetAvatarHandler)) + } + } + router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler)) if config.Mode == settings.MODE_DEVELOPMENT { @@ -245,6 +253,11 @@ func startWebServer(c *cli.Context) error { apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler)) apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config)) + if config.AvatarProvider == settings.InternalAvatarProvider { + apiV1Route.POST("/users/avatar/update.json", bindApi(api.Users.UserUpdateAvatarHandler)) + apiV1Route.POST("/users/avatar/remove.json", bindApi(api.Users.UserRemoveAvatarHandler)) + } + if config.EnableUserVerifyEmail { apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler)) } @@ -397,6 +410,19 @@ func bindTsv(fn core.DataHandlerFunc) gin.HandlerFunc { } } +func bindImage(fn core.ImageHandlerFunc) gin.HandlerFunc { + return func(ginCtx *gin.Context) { + c := core.WrapContext(ginCtx) + result, contentType, err := fn(c) + + if err != nil { + utils.PrintDataErrorResult(c, "text/text", err) + } else { + utils.PrintDataSuccessResult(c, contentType, "", result) + } + } +} + func bindCachedPngImage(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc { return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) { c := core.WrapContext(ginCtx) diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index df673568..c9f77ef2 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -92,6 +92,13 @@ level = info # For "file" mode only, log file path (relative or absolute path) log_path = log/ezbookkeeping.log +[storage] +# Object storage type, supports "local_filesystem" currently +storage_type = local_filesystem + +# For "local_filesystem" storage only, the storage root path (relative or absolute path) +local_filesystem_path = storage/ + [uuid] # Uuid generator type, supports "internal" currently generator_type = internal @@ -153,9 +160,10 @@ enable_forget_password = true forget_password_require_email_verify = false # User avatar provider, supports the following types: +# "internal": Use the internal object storage to store user avatar (refer to "storage" settings), supports updating avatar by user self # "gravatar": https://gravatar.com # Leave blank if you want to disable user avatar -avatar_provider = +avatar_provider = internal [data] # Set to true to allow users to export their data diff --git a/pkg/api/users.go b/pkg/api/users.go index 98f68b79..91c08528 100644 --- a/pkg/api/users.go +++ b/pkg/api/users.go @@ -1,6 +1,8 @@ package api import ( + "io" + "os" "strings" "time" @@ -13,6 +15,8 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/storage" + "github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/validators" ) @@ -494,6 +498,128 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error) return resp, nil } +// UserUpdateAvatarHandler saves user avatar by request parameters for current user +func (a *UsersApi) UserUpdateAvatarHandler(c *core.Context) (any, *errs.Error) { + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(c, uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + form, err := c.MultipartForm() + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to get multi-part form data for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrParameterInvalid + } + + avatars := form.File["avatar"] + + if len(avatars) < 1 { + log.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] there is no user avatar in request for user \"uid:%d\"", user.Uid) + return nil, errs.ErrNoUserAvatar + } + + if avatars[0].Size < 1 { + log.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] the size of user avatar in request is zero for user \"uid:%d\"", user.Uid) + return nil, errs.ErrUserAvatarIsEmpty + } + + fileExtension := utils.GetFileNameExtension(avatars[0].Filename) + + if utils.GetImageContentType(fileExtension) == "" { + log.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] the file extension \"%s\" of user avatar in request is not supported for user \"uid:%d\"", fileExtension, user.Uid) + return nil, errs.ErrImageTypeNotSupported + } + + avatarFile, err := avatars[0].Open() + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to get avatar file from request for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrOperationFailed + } + + defer avatarFile.Close() + + err = storage.Container.SaveAvatar(user.Uid, avatarFile, fileExtension) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to save avatar file for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrOperationFailed + } + + err = a.users.UpdateUserAvatar(c, user.Uid, fileExtension) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + if fileExtension != user.CustomAvatarType { + err = storage.Container.DeleteAvatar(user.Uid, user.CustomAvatarType) + + if err != nil { + log.WarnfWithRequestId(c, "[users.UserUpdateAvatarHandler] failed to delete old avatar with extension \"%s\" for user \"uid:%d\", because %s", user.CustomAvatarType, user.Uid, err.Error()) + } + } + + user.CustomAvatarType = fileExtension + userResp := user.ToUserProfileResponse() + return userResp, nil +} + +// UserRemoveAvatarHandler removes user avatar by request parameters for current user +func (a *UsersApi) UserRemoveAvatarHandler(c *core.Context) (any, *errs.Error) { + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(c, uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to get user, because %s", err.Error()) + } + + return nil, errs.ErrUserNotFound + } + + if user.CustomAvatarType == "" { + return nil, errs.ErrNothingWillBeUpdated + } + + err = storage.Container.DeleteAvatar(user.Uid, user.CustomAvatarType) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to delete avatar file for user \"uid:%d\", because %s", user.Uid, err.Error()) + + exists, err := storage.Container.ExistsAvatar(user.Uid, user.CustomAvatarType) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to check whether avatar file exist for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.ErrOperationFailed + } + + if exists { + log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to delete whether avatar file exist for user \"uid:%d\", the avatar file still exist", user.Uid) + return nil, errs.ErrOperationFailed + } + } + + err = a.users.UpdateUserAvatar(c, user.Uid, "") + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserRemoveAvatarHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + user.CustomAvatarType = "" + userResp := user.ToUserProfileResponse() + return userResp, nil +} + // UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (any, *errs.Error) { if !settings.Container.Current.EnableUserVerifyEmail { @@ -593,3 +719,60 @@ func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (any return true, nil } + +// UserGetAvatarHandler returns user avatar data for current user +func (a *UsersApi) UserGetAvatarHandler(c *core.Context) ([]byte, string, *errs.Error) { + uid := c.GetCurrentUid() + user, err := a.users.GetUserById(c, uid) + + if err != nil { + if !errs.IsCustomError(err) { + log.ErrorfWithRequestId(c, "[users.UserGetAvatarHandler] failed to get user, because %s", err.Error()) + } + + return nil, "", errs.ErrUserNotFound + } + + if user.CustomAvatarType == "" { + log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] user does not have avatar for user \"uid:%d\"", user.Uid) + return nil, "", errs.ErrUserAvatarNoExists + } + + fileName := c.Param("fileName") + fileBaseName := utils.GetFileNameWithoutExtension(fileName) + + if utils.Int64ToString(user.Uid) != fileBaseName { + log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] cannot get other user avatar \"uid:%s\" for user \"uid:%d\"", fileBaseName, user.Uid) + return nil, "", errs.ErrUserIdInvalid + } + + fileExtension := utils.GetFileNameExtension(fileName) + + if user.CustomAvatarType != fileExtension { + log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] user avatar extension is invalid \"%s\" for user \"uid:%d\"", fileExtension, user.Uid) + return nil, "", errs.ErrUserAvatarNoExists + } + + avatarFile, err := storage.Container.ReadAvatar(user.Uid, fileExtension) + + if os.IsNotExist(err) { + log.WarnfWithRequestId(c, "[users.UserGetAvatarHandler] user avatar file not exist for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, "", errs.ErrUserAvatarNoExists + } + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserGetAvatarHandler] failed to get user avatar object for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, "", errs.ErrOperationFailed + } + + defer avatarFile.Close() + + avatarData, err := io.ReadAll(avatarFile) + + if err != nil { + log.ErrorfWithRequestId(c, "[users.UserGetAvatarHandler] failed to read user avatar object data for user \"uid:%d\", because %s", user.Uid, err.Error()) + return nil, "", errs.ErrOperationFailed + } + + return avatarData, utils.GetImageContentType(fileExtension), nil +} diff --git a/pkg/core/handler.go b/pkg/core/handler.go index 69268f27..4d24dd5a 100644 --- a/pkg/core/handler.go +++ b/pkg/core/handler.go @@ -12,8 +12,11 @@ type MiddlewareHandlerFunc func(*Context) // ApiHandlerFunc represents the api handler function type ApiHandlerFunc func(*Context) (any, *errs.Error) -// DataHandlerFunc represents the handler function that returns byte array +// DataHandlerFunc represents the handler function that returns file data byte array and file name type DataHandlerFunc func(*Context) ([]byte, string, *errs.Error) +// ImageHandlerFunc represents the handler function that returns image byte array and content type +type ImageHandlerFunc func(*Context) ([]byte, string, *errs.Error) + // ProxyHandlerFunc represents the reverse proxy handler function type ProxyHandlerFunc func(*Context) (*httputil.ReverseProxy, *errs.Error) diff --git a/pkg/errs/setting.go b/pkg/errs/setting.go index fea362cd..7dcc0c9d 100644 --- a/pkg/errs/setting.go +++ b/pkg/errs/setting.go @@ -9,16 +9,18 @@ var ( ErrInvalidLogMode = NewSystemError(SystemSubcategorySetting, 2, http.StatusInternalServerError, "invalid log mode") ErrInvalidLogLevel = NewSystemError(SystemSubcategorySetting, 3, http.StatusInternalServerError, "invalid log level") ErrGettingLocalAddress = NewSystemError(SystemSubcategorySetting, 4, http.StatusInternalServerError, "failed to get local address") - ErrInvalidUuidMode = NewSystemError(SystemSubcategorySetting, 5, http.StatusInternalServerError, "invalid uuid mode") - ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid duplicate checker type") - ErrInvalidInMemoryDuplicateCheckerCleanupInterval = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid in-memory duplicate checker cleanup interval") - ErrInvalidTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 8, http.StatusInternalServerError, "invalid token expired time") - ErrInvalidTokenMinRefreshInterval = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid token min refresh interval") - ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid temporary token expired time") - ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid email verify token expired time") - ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid avatar provider") - ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid map provider") - ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid amap security verification method") - ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid password reset token expired time") - ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 16, http.StatusInternalServerError, "invalid exchange rates data source") + ErrInvalidStorageType = NewSystemError(SystemSubcategorySetting, 5, http.StatusInternalServerError, "invalid storage type") + ErrInvalidLocalFileSystemStoragePath = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid local file system storage path") + ErrInvalidUuidMode = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid uuid mode") + ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 8, http.StatusInternalServerError, "invalid duplicate checker type") + ErrInvalidInMemoryDuplicateCheckerCleanupInterval = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid in-memory duplicate checker cleanup interval") + ErrInvalidTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid token expired time") + ErrInvalidTokenMinRefreshInterval = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid token min refresh interval") + ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid temporary token expired time") + ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid email verify token expired time") + ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid avatar provider") + ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid map provider") + ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 16, http.StatusInternalServerError, "invalid amap security verification method") + ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 17, http.StatusInternalServerError, "invalid password reset token expired time") + ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 18, http.StatusInternalServerError, "invalid exchange rates data source") ) diff --git a/pkg/errs/system.go b/pkg/errs/system.go index 39f6729a..04b6f9e8 100644 --- a/pkg/errs/system.go +++ b/pkg/errs/system.go @@ -4,10 +4,11 @@ import "net/http" // Error codes related to transaction categories var ( - ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error") - ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found") - ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed") - ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented") - ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy") - ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported") + ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error") + ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found") + ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed") + ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented") + ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy") + ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported") + ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported") ) diff --git a/pkg/errs/user.go b/pkg/errs/user.go index 5592682c..26ab5e41 100644 --- a/pkg/errs/user.go +++ b/pkg/errs/user.go @@ -31,4 +31,7 @@ var ( ErrEmailValidationNotAllowed = NewNormalError(NormalSubcategoryUser, 22, http.StatusBadRequest, "email validation not allowed") ErrDecimalSeparatorAndDigitGroupingSymbolCannotBeEqual = NewNormalError(NormalSubcategoryUser, 23, http.StatusBadRequest, "decimal separator and digit grouping symbol cannot be equal") ErrUserDefaultAccountIsHidden = NewNormalError(NormalSubcategoryUser, 24, http.StatusBadRequest, "user default account is hidden") + ErrNoUserAvatar = NewNormalError(NormalSubcategoryUser, 25, http.StatusBadRequest, "no user avatar") + ErrUserAvatarIsEmpty = NewNormalError(NormalSubcategoryUser, 26, http.StatusBadRequest, "user avatar is empty") + ErrUserAvatarNoExists = NewNormalError(NormalSubcategoryUser, 27, http.StatusNotFound, "user avatar not exists") ) diff --git a/pkg/models/user.go b/pkg/models/user.go index a3c54fc8..380ee3ea 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -88,6 +88,7 @@ type User struct { Nickname string `xorm:"VARCHAR(64) NOT NULL"` Password string `xorm:"VARCHAR(64) NOT NULL"` Salt string `xorm:"VARCHAR(10) NOT NULL"` + CustomAvatarType string `xorm:"VARCHAR(10)"` DefaultAccountId int64 TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"` Language string `xorm:"VARCHAR(10)"` @@ -313,7 +314,9 @@ func (u *User) getAvatarProvider() string { func (u *User) getAvatarUrl() string { avatarProvider := settings.Container.Current.AvatarProvider - if avatarProvider == settings.GravatarProvider { + if avatarProvider == settings.InternalAvatarProvider { + return utils.GetInternalAvatarUrl(u.Uid, u.CustomAvatarType, settings.Container.Current.RootUrl) + } else if avatarProvider == settings.GravatarProvider { return utils.GetGravatarUrl(u.Email) } diff --git a/pkg/services/users.go b/pkg/services/users.go index 4dd48faa..32355548 100644 --- a/pkg/services/users.go +++ b/pkg/services/users.go @@ -294,6 +294,25 @@ func (s *UserService) UpdateUser(c *core.Context, user *models.User, modifyUserL return keyProfileUpdated, emailSetToUnverified, nil } +// UpdateUserAvatar updated the custom avatar type of specified user +func (s *UserService) UpdateUserAvatar(c *core.Context, uid int64, customAvatarType string) error { + if uid <= 0 { + return errs.ErrUserIdInvalid + } + + now := time.Now().Unix() + + updateModel := &models.User{ + CustomAvatarType: customAvatarType, + UpdatedUnixTime: now, + } + + return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error { + _, err := sess.ID(uid).Cols("custom_avatar_type", "updated_unix_time").Where("deleted=?", false).Update(updateModel) + return err + }) +} + // UpdateUserLastLoginTime updates the last login time field func (s *UserService) UpdateUserLastLoginTime(c *core.Context, uid int64) error { if uid <= 0 { diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 3fdc42b4..4d7c3ac2 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -57,6 +57,11 @@ const ( Sqlite3DbType string = "sqlite3" ) +// Object Storage types +const ( + LocalFileSystemObjectStorageType string = "local_filesystem" +) + // Uuid generator types const ( InternalUuidGeneratorType string = "internal" @@ -69,7 +74,8 @@ const ( // User avatar provider types const ( - GravatarProvider string = "gravatar" + InternalAvatarProvider string = "internal" + GravatarProvider string = "gravatar" ) // Map provider types @@ -201,6 +207,10 @@ type Config struct { LogLevel Level FileLogPath string + // Storage + StorageType string + LocalFileSystemPath string + // Uuid UuidGeneratorType string UuidServerId uint8 @@ -311,6 +321,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) { return nil, err } + err = loadStorageConfiguration(config, cfgFile, "storage") + + if err != nil { + return nil, err + } + err = loadUuidConfiguration(config, cfgFile, "uuid") if err != nil { @@ -523,6 +539,24 @@ func loadLogConfiguration(config *Config, configFile *ini.File, sectionName stri return nil } +func loadStorageConfiguration(config *Config, configFile *ini.File, sectionName string) error { + if getConfigItemStringValue(configFile, sectionName, "storage_type") == LocalFileSystemObjectStorageType { + config.StorageType = LocalFileSystemObjectStorageType + } else { + return errs.ErrInvalidStorageType + } + + localFileSystemRootPath := getConfigItemStringValue(configFile, sectionName, "local_filesystem_path") + finalLocalFileSystemRootPath, err := getFinalPath(config.WorkingPath, localFileSystemRootPath) + config.LocalFileSystemPath = finalLocalFileSystemRootPath + + if config.StorageType == LocalFileSystemObjectStorageType && err != nil { + return errs.ErrInvalidLocalFileSystemStoragePath + } + + return nil +} + func loadUuidConfiguration(config *Config, configFile *ini.File, sectionName string) error { if getConfigItemStringValue(configFile, sectionName, "generator_type") == InternalUuidGeneratorType { config.UuidGeneratorType = InternalUuidGeneratorType @@ -619,10 +653,12 @@ func loadUserConfiguration(config *Config, configFile *ini.File, sectionName str config.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false) config.ForgetPasswordRequireVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "forget_password_require_email_verify", false) - if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == "" { - config.AvatarProvider = "" + if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == InternalAvatarProvider { + config.AvatarProvider = InternalAvatarProvider } else if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == GravatarProvider { config.AvatarProvider = GravatarProvider + } else if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == "" { + config.AvatarProvider = "" } else { return errs.ErrInvalidAvatarProvider } diff --git a/pkg/storage/local_filesystem_storage.go b/pkg/storage/local_filesystem_storage.go new file mode 100644 index 00000000..9f71eac0 --- /dev/null +++ b/pkg/storage/local_filesystem_storage.go @@ -0,0 +1,68 @@ +package storage + +import ( + "io" + "os" + "path/filepath" + + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" +) + +// LocalFileSystemObjectStorage represents local file system object storage +type LocalFileSystemObjectStorage struct { + rootPath string +} + +// NewLocalFileSystemObjectStorage returns a local file system object storage +func NewLocalFileSystemObjectStorage(config *settings.Config, pathPrefix string) (*LocalFileSystemObjectStorage, error) { + storage := &LocalFileSystemObjectStorage{ + rootPath: filepath.Join(config.LocalFileSystemPath, pathPrefix), + } + + if err := os.MkdirAll(storage.rootPath, os.ModePerm); err != nil { + return nil, err + } + + return storage, nil +} + +// Exists returns whether the file exists +func (s *LocalFileSystemObjectStorage) Exists(path string) (bool, error) { + return utils.IsExists(s.getFinalPath(path)) +} + +// Read returns the object instance according to specified the file path +func (s *LocalFileSystemObjectStorage) Read(path string) (ObjectInStorage, error) { + return os.Open(s.getFinalPath(path)) +} + +// Save returns whether save the object instance successfully +func (s *LocalFileSystemObjectStorage) Save(path string, object ObjectInStorage) error { + finalPath := s.getFinalPath(path) + + if err := os.MkdirAll(filepath.Dir(finalPath), os.ModePerm); err != nil { + return err + } + + targetFile, err := os.Create(finalPath) + + if err != nil { + return err + } + + defer targetFile.Close() + + _, err = io.Copy(targetFile, object) + + return err +} + +// Delete returns whether delete the object according to specified the file path successfully +func (s *LocalFileSystemObjectStorage) Delete(path string) error { + return os.Remove(s.getFinalPath(path)) +} + +func (s *LocalFileSystemObjectStorage) getFinalPath(path string) string { + return filepath.Join(s.rootPath, path) +} diff --git a/pkg/storage/object_in_storage.go b/pkg/storage/object_in_storage.go new file mode 100644 index 00000000..6f75b56e --- /dev/null +++ b/pkg/storage/object_in_storage.go @@ -0,0 +1,11 @@ +package storage + +import ( + "io" +) + +// ObjectInStorage represents the object instance in the storage +type ObjectInStorage interface { + io.ReadCloser + io.Seeker +} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go new file mode 100644 index 00000000..40742bea --- /dev/null +++ b/pkg/storage/storage.go @@ -0,0 +1,9 @@ +package storage + +// ObjectStorage represents an object storage to store file object +type ObjectStorage interface { + Exists(path string) (bool, error) + Read(path string) (ObjectInStorage, error) + Save(path string, object ObjectInStorage) error + Delete(path string) error +} diff --git a/pkg/storage/storage_container.go b/pkg/storage/storage_container.go new file mode 100644 index 00000000..a3d87816 --- /dev/null +++ b/pkg/storage/storage_container.go @@ -0,0 +1,56 @@ +package storage + +import ( + "fmt" + + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/settings" +) + +const avatarPathPrefix = "avatar" + +// StorageContainer contains the current object storage +type StorageContainer struct { + AvatarCurrentStorage ObjectStorage +} + +// Initialize a object storage container singleton instance +var ( + Container = &StorageContainer{} +) + +// InitializeStorageContainer initializes the current object storage according to the config +func InitializeStorageContainer(config *settings.Config) error { + if config.StorageType == settings.LocalFileSystemObjectStorageType { + storage, err := NewLocalFileSystemObjectStorage(config, avatarPathPrefix) + Container.AvatarCurrentStorage = storage + + return err + } + + return errs.ErrInvalidStorageType +} + +// ExistsAvatar returns whether the user avatar exists from the current object storage +func (s *StorageContainer) ExistsAvatar(uid int64, fileExtension string) (bool, error) { + return s.AvatarCurrentStorage.Exists(s.getUserAvatarPath(uid, fileExtension)) +} + +// ReadAvatar returns the user avatar from the current object storage +func (s *StorageContainer) ReadAvatar(uid int64, fileExtension string) (ObjectInStorage, error) { + return s.AvatarCurrentStorage.Read(s.getUserAvatarPath(uid, fileExtension)) +} + +// SaveAvatar returns whether save the user avatar into the current object storage successfully +func (s *StorageContainer) SaveAvatar(uid int64, object ObjectInStorage, fileExtension string) error { + return s.AvatarCurrentStorage.Save(s.getUserAvatarPath(uid, fileExtension), object) +} + +// DeleteAvatar returns whether delete the user avatar from the current object storage successfully +func (s *StorageContainer) DeleteAvatar(uid int64, fileExtension string) error { + return s.AvatarCurrentStorage.Delete(s.getUserAvatarPath(uid, fileExtension)) +} + +func (s *StorageContainer) getUserAvatarPath(uid int64, fileExtension string) string { + return fmt.Sprintf("%d.%s", uid, fileExtension) +} diff --git a/pkg/utils/avatar.go b/pkg/utils/avatar.go index 620aca0f..00e06060 100644 --- a/pkg/utils/avatar.go +++ b/pkg/utils/avatar.go @@ -7,6 +7,15 @@ import ( const gravatarUrlFormat = "https://www.gravatar.com/avatar/%s" +// GetInternalAvatarUrl returns the internal avatar url +func GetInternalAvatarUrl(uid int64, avatarFileExtesion string, webRootUrl string) string { + if avatarFileExtesion == "" { + return "" + } + + return fmt.Sprintf("%savatar/%d.%s", webRootUrl, uid, avatarFileExtesion) +} + // GetGravatarUrl returns the Gravatar url according to the specified user email address func GetGravatarUrl(email string) string { email = strings.TrimSpace(email) diff --git a/pkg/utils/avatar_test.go b/pkg/utils/avatar_test.go index ef7b52a3..e8e3747a 100644 --- a/pkg/utils/avatar_test.go +++ b/pkg/utils/avatar_test.go @@ -6,6 +6,16 @@ import ( "github.com/stretchr/testify/assert" ) +func TestGetInternalAvatarUrl(t *testing.T) { + expectedValue := "https://demo.ezbookkeeping.mayswind.net/avatar/1234567890.jpg" + actualValue := GetInternalAvatarUrl(1234567890, "jpg", "https://demo.ezbookkeeping.mayswind.net/") + assert.Equal(t, expectedValue, actualValue) + + expectedValue = "" + actualValue = GetInternalAvatarUrl(1234567890, "", "https://demo.ezbookkeeping.mayswind.net/") + assert.Equal(t, expectedValue, actualValue) +} + func TestGetGravatarUrl(t *testing.T) { // Reference: https://en.gravatar.com/site/implement/hash/ expectedValue := "https://www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346" diff --git a/pkg/utils/io.go b/pkg/utils/io.go index 6945a25e..01fdda56 100644 --- a/pkg/utils/io.go +++ b/pkg/utils/io.go @@ -3,9 +3,29 @@ package utils import ( "io" "os" + "path/filepath" "strings" ) +var imageFileExtensionContentTypeMap = map[string]string{ + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", +} + +// GetImageContentType returns the content type of specified image file extension or returns empty when the file extension is not image or not supported +func GetImageContentType(fileExtension string) string { + contentType, exists := imageFileExtensionContentTypeMap[fileExtension] + + if !exists { + return "" + } + + return contentType +} + // ListFileNamesWithPrefixAndSuffix returns file name list which has specified prefix and suffix func ListFileNamesWithPrefixAndSuffix(path string, prefix string, suffix string) []string { dir, err := os.Open(path) @@ -69,6 +89,29 @@ func WriteFile(path string, data []byte) error { return err } +// GetFileNameWithoutExtension returns the file name without extension +func GetFileNameWithoutExtension(path string) string { + fileName := filepath.Base(path) + extension := filepath.Ext(fileName) + + if len(extension) < 1 { + return fileName + } + + return fileName[0 : len(fileName)-len(extension)] +} + +// GetFileNameExtension returns the file extension without dot +func GetFileNameExtension(path string) string { + extension := filepath.Ext(path) + + if len(extension) < 1 || extension[0] != '.' { + return extension + } + + return extension[1:] +} + // IdentReader returns the original io reader func IdentReader(encoding string, input io.Reader) (io.Reader, error) { return input, nil diff --git a/pkg/utils/io_test.go b/pkg/utils/io_test.go new file mode 100644 index 00000000..47e87622 --- /dev/null +++ b/pkg/utils/io_test.go @@ -0,0 +1,143 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetImageContentType(t *testing.T) { + fileName := "gif" + expectedContentType := "image/gif" + actualContentType := GetImageContentType(fileName) + assert.Equal(t, expectedContentType, actualContentType) + + fileName = "bmp" + expectedContentType = "" + actualContentType = GetImageContentType(fileName) + assert.Equal(t, expectedContentType, actualContentType) +} + +func TestGetFileNameWithoutExtension(t *testing.T) { + fileName := "name.ext" + expectedName := "name" + actualName := GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "C:\\name.ext" + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "/root/name.ext" + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "name" + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "C:\\name" + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "/root/name" + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "name." + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "C:\\name." + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "/root/name." + expectedName = "name" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = ".ext" + expectedName = "" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "C:\\.ext" + expectedName = "" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) + + fileName = "/root/.ext" + expectedName = "" + actualName = GetFileNameWithoutExtension(fileName) + assert.Equal(t, expectedName, actualName) +} + +func TestGetFileNameExtension(t *testing.T) { + fileName := "name.ext" + expectedExt := "ext" + actualExt := GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "C:\\name.ext" + expectedExt = "ext" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "/root/name.ext" + expectedExt = "ext" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "name" + expectedExt = "" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "C:\\name" + expectedExt = "" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "/root/name" + expectedExt = "" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "name." + expectedExt = "" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "C:\\name." + expectedExt = "" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "/root/name." + expectedExt = "" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = ".ext" + expectedExt = "ext" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "C:\\.ext" + expectedExt = "ext" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) + + fileName = "/root/.ext" + expectedExt = "ext" + actualExt = GetFileNameExtension(fileName) + assert.Equal(t, expectedExt, actualExt) +} diff --git a/src/consts/file.js b/src/consts/file.js new file mode 100644 index 00000000..6050d7a6 --- /dev/null +++ b/src/consts/file.js @@ -0,0 +1,5 @@ +const supportedImageExtensions = '.jpg,.jpeg,.png,.gif,.webp'; + +export default { + supportedImageExtensions: supportedImageExtensions +} diff --git a/src/lib/services.js b/src/lib/services.js index a04a562a..09256976 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -2,12 +2,14 @@ import axios from 'axios'; import apiConstants from '@/consts/api.js'; import userState from './userstate.js'; +import { isBoolean } from './common.js'; import { getGoogleMapAPIKey, getBaiduMapAK, getAmapApplicationKey } from './server_settings.js'; import { getTimezoneOffsetMinutes } from './datetime.js'; +import { generateRandomUUID } from './misc.js'; let needBlockRequest = false; let blockedRequests = []; @@ -192,6 +194,14 @@ export default { incomeAmountColor }); }, + updateAvatar: ({ avatarFile }) => { + return axios.postForm('v1/users/avatar/update.json', { + avatar: avatarFile + }); + }, + removeAvatar: () => { + return axios.post('v1/users/avatar/remove.json'); + }, resendVerifyEmailByLoginedUser: () => { return axios.post('v1/users/verify_email/resend.json'); }, @@ -552,5 +562,27 @@ export default { }, generateAmapApiInternalProxyUrl: () => { return `${window.location.origin}${apiConstants.baseAmapApiProxyUrlPath}`; + }, + getInternalAvatarUrlWithToken(avatarUrl, disableBrowserCache) { + if (!avatarUrl) { + return avatarUrl; + } + + const params = []; + params.push('token=' + userState.getToken()); + + if (disableBrowserCache) { + if (isBoolean(disableBrowserCache)) { + params.push('_nocache=' + generateRandomUUID()); + } else { + params.push('_nocache=' + disableBrowserCache); + } + } + + if (avatarUrl.indexOf('?') >= 0) { + return avatarUrl + '&' + params.join('&'); + } else { + return avatarUrl + '?' + params.join('&'); + } } }; diff --git a/src/locales/en.js b/src/locales/en.js index 82768081..20c02c6c 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -592,6 +592,7 @@ export default { 'not implemented': 'Not implemented', 'system is busy': 'System is busy', 'not supported': 'Not supported', + 'image type not supported': 'Image type is not supported', 'database operation failed': 'Database operation failed', 'SMTP server is not enabled': 'SMTP server is not enabled', 'incomplete or incorrect submission': 'Incomplete or incorrect submission', @@ -624,6 +625,9 @@ export default { 'email validation not allowed': 'Email validation is not allowed', 'decimal separator and digit grouping symbol cannot be equal': 'Decimal separator and digit grouping symbol cannot be equal', 'user default account is hidden': 'Cannot set hidden account as default account', + 'no user avatar': 'There is no user avatar file', + 'user avatar is empty': 'User avatar file is empty', + 'user avatar not exists': 'User avatar does not exist', 'unauthorized access': 'Unauthorized access', 'current token is invalid': 'Current token is invalid', 'current token is expired': 'Current token is expired', @@ -1159,6 +1163,8 @@ export default { 'Basic Settings': 'Basic Settings', 'Security Settings': 'Security Settings', 'Two-Factor Authentication Settings': 'Two-Factor Authentication Settings', + 'Update Avatar': 'Update Avatar', + 'Remove Avatar': 'Remove Avatar', '(Verified)': '(Verified)', '(Not Verified)': '(Not Verified)', 'Email address is verified': 'Email address is verified', @@ -1170,6 +1176,11 @@ export default { 'Please enter your current password when modifying your password': 'Please enter your current password when modifying your password', 'Nothing has been modified': 'Nothing has been modified', 'Your profile has been successfully updated': 'Your profile has been successfully updated', + 'Unable to update user avatar': 'Unable to update user avatar', + 'Your avatar has been successfully updated': 'Your avatar has been successfully updated', + 'Are you sure you want to remove avatar?': 'Are you sure you want to remove avatar?', + 'Unable to remove user avatar': 'Unable to remove user avatar', + 'Your avatar has been successfully removed': 'Your avatar has been successfully removed', 'Unable to update user profile': 'Unable to update user profile', 'After changing the password, other devices will be logged out. Please use the new password to log in on other devices.': 'After changing the password, other devices will be logged out. Please use the new password to log in on other devices.', 'Data Management': 'Data Management', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index 38505c70..4da25a3e 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -592,6 +592,7 @@ export default { 'not implemented': '未实现', 'system is busy': '系统繁忙', 'not supported': '不支持', + 'image type not supported': '图片类型不支持', 'database operation failed': '数据库操作失败', 'SMTP server is not enabled': 'SMTP 服务器没有启用', 'incomplete or incorrect submission': '提交不完整或不正确', @@ -624,6 +625,9 @@ export default { 'email validation not allowed': '不允许邮箱验证', 'decimal separator and digit grouping symbol cannot be equal': '小数点和数字分组符号不能相同', 'user default account is hidden': '不能把隐藏账户设置为默认账户', + 'no user avatar': '没有用户头像文件', + 'user avatar is empty': '用户头像文件为空', + 'user avatar not exists': '用户头像不存在', 'unauthorized access': '未授权的登录', 'current token is invalid': '当前认证令牌无效', 'current token is expired': '当前认证令牌已过期', @@ -1159,6 +1163,8 @@ export default { 'Basic Settings': '基本设置', 'Security Settings': '安全设置', 'Two-Factor Authentication Settings': '两步验证设置', + 'Update Avatar': '更新头像', + 'Remove Avatar': '删除头像', '(Verified)': '(已验证)', '(Not Verified)': '(未验证)', 'Email address is verified': '邮箱地址已验证', @@ -1170,6 +1176,11 @@ export default { 'Please enter your current password when modifying your password': '修改密码时请输入您的当前密码', 'Nothing has been modified': '没有修改的项目', 'Your profile has been successfully updated': '您的用户信息更新成功', + 'Unable to update user avatar': '无法更新用户头像', + 'Your avatar has been successfully updated': '您的头像更新成功', + 'Are you sure you want to remove avatar?': '您确定要删除头像?', + 'Unable to remove user avatar': '无法删除用户头像', + 'Your avatar has been successfully removed': '您的用户头像删除成功', 'Unable to update user profile': '无法更新用户信息', 'After changing the password, other devices will be logged out. Please use the new password to log in on other devices.': '密码修改后,其他设备将会退出登录,请使用新密码在其他设备上重新登录。', 'Data Management': '数据管理', diff --git a/src/stores/user.js b/src/stores/user.js index 91c6a5f4..65696d36 100644 --- a/src/stores/user.js +++ b/src/stores/user.js @@ -18,7 +18,7 @@ export const useUserStore = defineStore('user', { }, currentUserAvatar(state) { const userInfo = state.currentUserBasicInfo || {}; - return userInfo.avatar || null; + return state.getUserAvatarUrl(userInfo, false); }, currentUserDefaultAccountId(state) { const userInfo = state.currentUserBasicInfo || {}; @@ -126,6 +126,54 @@ export const useUserStore = defineStore('user', { }); }); }, + updateUserAvatar({ avatarFile }) { + return new Promise((resolve, reject) => { + services.updateAvatar({ avatarFile }).then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to update user avatar' }); + return; + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to update user avatar', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to update user avatar' }); + } else { + reject(error); + } + }); + }); + }, + removeUserAvatar() { + return new Promise((resolve, reject) => { + services.removeAvatar().then(response => { + const data = response.data; + + if (!data || !data.success || !data.result) { + reject({ message: 'Unable to remove user avatar' }); + return; + } + + resolve(data.result); + }).catch(error => { + logger.error('failed to remove user avatar', error); + + if (error.response && error.response.data && error.response.data.errorMessage) { + reject({ error: error.response.data }); + } else if (!error.processed) { + reject({ message: 'Unable to remove user avatar' }); + } else { + reject(error); + } + }); + }); + }, getUserDataStatistics() { return new Promise((resolve, reject) => { services.getUserDataStatistics().then(response => { @@ -178,5 +226,12 @@ export const useUserStore = defineStore('user', { }); }); }, + getUserAvatarUrl(userInfo, disableBrowserCache) { + if (!userInfo || !userInfo.avatar) { + return null; + } + + return services.getInternalAvatarUrlWithToken(userInfo.avatar, disableBrowserCache); + } } }); diff --git a/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue b/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue index 1ee1ce56..110f5e4c 100644 --- a/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue +++ b/src/views/desktop/user/settings/tabs/UserBasicSettingTab.vue @@ -8,15 +8,21 @@ - - + + - + + + + + + +
@@ -301,6 +307,7 @@ +