supports local file system object storage and use it as the default avatar provider

This commit is contained in:
MaysWind
2024-07-27 23:29:18 +08:00
parent 731b6e8bad
commit 2e04affb00
26 changed files with 858 additions and 29 deletions
+2 -1
View File
@@ -27,7 +27,8 @@ COPY docker/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
RUN mkdir -p /ezbookkeeping && chown 1000:1000 /ezbookkeeping \ RUN mkdir -p /ezbookkeeping && chown 1000:1000 /ezbookkeeping \
&& mkdir -p /ezbookkeeping/data && chown 1000:1000 /ezbookkeeping/data \ && 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 WORKDIR /ezbookkeeping
COPY --from=be-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/ezbookkeeping /ezbookkeeping/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 COPY --from=fe-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/dist /ezbookkeeping/public
+10
View File
@@ -12,6 +12,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/mail" "github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/storage"
"github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/uuid" "github.com/mayswind/ezbookkeeping/pkg/uuid"
) )
@@ -80,6 +81,15 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
return nil, err 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) err = uuid.InitializeUuidGenerator(config)
if err != nil { if err != nil {
+26
View File
@@ -145,6 +145,14 @@ func startWebServer(c *cli.Context) error {
router.StaticFile("/desktop/"+workboxFileNames[i], filepath.Join(config.StaticRootPath, workboxFileNames[i])) 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)) router.GET("/healthz.json", bindApi(api.Healths.HealthStatusHandler))
if config.Mode == settings.MODE_DEVELOPMENT { 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.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler))
apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config)) 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 { if config.EnableUserVerifyEmail {
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler)) 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 { func bindCachedPngImage(fn core.DataHandlerFunc, store persistence.CacheStore) gin.HandlerFunc {
return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) { return cache.CachePage(store, time.Minute, func(ginCtx *gin.Context) {
c := core.WrapContext(ginCtx) c := core.WrapContext(ginCtx)
+9 -1
View File
@@ -92,6 +92,13 @@ level = info
# For "file" mode only, log file path (relative or absolute path) # For "file" mode only, log file path (relative or absolute path)
log_path = log/ezbookkeeping.log 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]
# Uuid generator type, supports "internal" currently # Uuid generator type, supports "internal" currently
generator_type = internal generator_type = internal
@@ -153,9 +160,10 @@ enable_forget_password = true
forget_password_require_email_verify = false forget_password_require_email_verify = false
# User avatar provider, supports the following types: # 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 # "gravatar": https://gravatar.com
# Leave blank if you want to disable user avatar # Leave blank if you want to disable user avatar
avatar_provider = avatar_provider = internal
[data] [data]
# Set to true to allow users to export their data # Set to true to allow users to export their data
+183
View File
@@ -1,6 +1,8 @@
package api package api
import ( import (
"io"
"os"
"strings" "strings"
"time" "time"
@@ -13,6 +15,8 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services" "github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/storage"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/validators" "github.com/mayswind/ezbookkeeping/pkg/validators"
) )
@@ -494,6 +498,128 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (any, *errs.Error)
return resp, nil 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 // UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email
func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (any, *errs.Error) { func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (any, *errs.Error) {
if !settings.Container.Current.EnableUserVerifyEmail { if !settings.Container.Current.EnableUserVerifyEmail {
@@ -593,3 +719,60 @@ func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (any
return true, nil 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
}
+4 -1
View File
@@ -12,8 +12,11 @@ type MiddlewareHandlerFunc func(*Context)
// ApiHandlerFunc represents the api handler function // ApiHandlerFunc represents the api handler function
type ApiHandlerFunc func(*Context) (any, *errs.Error) 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) 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 // ProxyHandlerFunc represents the reverse proxy handler function
type ProxyHandlerFunc func(*Context) (*httputil.ReverseProxy, *errs.Error) type ProxyHandlerFunc func(*Context) (*httputil.ReverseProxy, *errs.Error)
+14 -12
View File
@@ -9,16 +9,18 @@ var (
ErrInvalidLogMode = NewSystemError(SystemSubcategorySetting, 2, http.StatusInternalServerError, "invalid log mode") ErrInvalidLogMode = NewSystemError(SystemSubcategorySetting, 2, http.StatusInternalServerError, "invalid log mode")
ErrInvalidLogLevel = NewSystemError(SystemSubcategorySetting, 3, http.StatusInternalServerError, "invalid log level") ErrInvalidLogLevel = NewSystemError(SystemSubcategorySetting, 3, http.StatusInternalServerError, "invalid log level")
ErrGettingLocalAddress = NewSystemError(SystemSubcategorySetting, 4, http.StatusInternalServerError, "failed to get local address") ErrGettingLocalAddress = NewSystemError(SystemSubcategorySetting, 4, http.StatusInternalServerError, "failed to get local address")
ErrInvalidUuidMode = NewSystemError(SystemSubcategorySetting, 5, http.StatusInternalServerError, "invalid uuid mode") ErrInvalidStorageType = NewSystemError(SystemSubcategorySetting, 5, http.StatusInternalServerError, "invalid storage type")
ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid duplicate checker type") ErrInvalidLocalFileSystemStoragePath = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid local file system storage path")
ErrInvalidInMemoryDuplicateCheckerCleanupInterval = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid in-memory duplicate checker cleanup interval") ErrInvalidUuidMode = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid uuid mode")
ErrInvalidTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 8, http.StatusInternalServerError, "invalid token expired time") ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 8, http.StatusInternalServerError, "invalid duplicate checker type")
ErrInvalidTokenMinRefreshInterval = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid token min refresh interval") ErrInvalidInMemoryDuplicateCheckerCleanupInterval = NewSystemError(SystemSubcategorySetting, 9, http.StatusInternalServerError, "invalid in-memory duplicate checker cleanup interval")
ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid temporary token expired time") ErrInvalidTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 10, http.StatusInternalServerError, "invalid token expired time")
ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid email verify token expired time") ErrInvalidTokenMinRefreshInterval = NewSystemError(SystemSubcategorySetting, 11, http.StatusInternalServerError, "invalid token min refresh interval")
ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid avatar provider") ErrInvalidTemporaryTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 12, http.StatusInternalServerError, "invalid temporary token expired time")
ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid map provider") ErrInvalidEmailVerifyTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 13, http.StatusInternalServerError, "invalid email verify token expired time")
ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid amap security verification method") ErrInvalidAvatarProvider = NewSystemError(SystemSubcategorySetting, 14, http.StatusInternalServerError, "invalid avatar provider")
ErrInvalidPasswordResetTokenExpiredTime = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid password reset token expired time") ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 15, http.StatusInternalServerError, "invalid map provider")
ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 16, http.StatusInternalServerError, "invalid exchange rates data source") 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")
) )
+7 -6
View File
@@ -4,10 +4,11 @@ import "net/http"
// Error codes related to transaction categories // Error codes related to transaction categories
var ( var (
ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error") ErrSystemError = NewSystemError(SystemSubcategoryDefault, 0, http.StatusInternalServerError, "system error")
ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found") ErrApiNotFound = NewSystemError(SystemSubcategoryDefault, 1, http.StatusNotFound, "api not found")
ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed") ErrMethodNotAllowed = NewSystemError(SystemSubcategoryDefault, 2, http.StatusMethodNotAllowed, "method not allowed")
ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented") ErrNotImplemented = NewSystemError(SystemSubcategoryDefault, 3, http.StatusNotImplemented, "not implemented")
ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy") ErrSystemIsBusy = NewSystemError(SystemSubcategoryDefault, 4, http.StatusServiceUnavailable, "system is busy")
ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported") ErrNotSupported = NewSystemError(SystemSubcategoryDefault, 5, http.StatusBadRequest, "not supported")
ErrImageTypeNotSupported = NewSystemError(SystemSubcategoryDefault, 6, http.StatusBadRequest, "image type not supported")
) )
+3
View File
@@ -31,4 +31,7 @@ var (
ErrEmailValidationNotAllowed = NewNormalError(NormalSubcategoryUser, 22, http.StatusBadRequest, "email validation not allowed") 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") 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") 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")
) )
+4 -1
View File
@@ -88,6 +88,7 @@ type User struct {
Nickname string `xorm:"VARCHAR(64) NOT NULL"` Nickname string `xorm:"VARCHAR(64) NOT NULL"`
Password string `xorm:"VARCHAR(64) NOT NULL"` Password string `xorm:"VARCHAR(64) NOT NULL"`
Salt string `xorm:"VARCHAR(10) NOT NULL"` Salt string `xorm:"VARCHAR(10) NOT NULL"`
CustomAvatarType string `xorm:"VARCHAR(10)"`
DefaultAccountId int64 DefaultAccountId int64
TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"` TransactionEditScope TransactionEditScope `xorm:"TINYINT NOT NULL"`
Language string `xorm:"VARCHAR(10)"` Language string `xorm:"VARCHAR(10)"`
@@ -313,7 +314,9 @@ func (u *User) getAvatarProvider() string {
func (u *User) getAvatarUrl() string { func (u *User) getAvatarUrl() string {
avatarProvider := settings.Container.Current.AvatarProvider 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) return utils.GetGravatarUrl(u.Email)
} }
+19
View File
@@ -294,6 +294,25 @@ func (s *UserService) UpdateUser(c *core.Context, user *models.User, modifyUserL
return keyProfileUpdated, emailSetToUnverified, nil 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 // UpdateUserLastLoginTime updates the last login time field
func (s *UserService) UpdateUserLastLoginTime(c *core.Context, uid int64) error { func (s *UserService) UpdateUserLastLoginTime(c *core.Context, uid int64) error {
if uid <= 0 { if uid <= 0 {
+39 -3
View File
@@ -57,6 +57,11 @@ const (
Sqlite3DbType string = "sqlite3" Sqlite3DbType string = "sqlite3"
) )
// Object Storage types
const (
LocalFileSystemObjectStorageType string = "local_filesystem"
)
// Uuid generator types // Uuid generator types
const ( const (
InternalUuidGeneratorType string = "internal" InternalUuidGeneratorType string = "internal"
@@ -69,7 +74,8 @@ const (
// User avatar provider types // User avatar provider types
const ( const (
GravatarProvider string = "gravatar" InternalAvatarProvider string = "internal"
GravatarProvider string = "gravatar"
) )
// Map provider types // Map provider types
@@ -201,6 +207,10 @@ type Config struct {
LogLevel Level LogLevel Level
FileLogPath string FileLogPath string
// Storage
StorageType string
LocalFileSystemPath string
// Uuid // Uuid
UuidGeneratorType string UuidGeneratorType string
UuidServerId uint8 UuidServerId uint8
@@ -311,6 +321,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) {
return nil, err return nil, err
} }
err = loadStorageConfiguration(config, cfgFile, "storage")
if err != nil {
return nil, err
}
err = loadUuidConfiguration(config, cfgFile, "uuid") err = loadUuidConfiguration(config, cfgFile, "uuid")
if err != nil { if err != nil {
@@ -523,6 +539,24 @@ func loadLogConfiguration(config *Config, configFile *ini.File, sectionName stri
return nil 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 { func loadUuidConfiguration(config *Config, configFile *ini.File, sectionName string) error {
if getConfigItemStringValue(configFile, sectionName, "generator_type") == InternalUuidGeneratorType { if getConfigItemStringValue(configFile, sectionName, "generator_type") == InternalUuidGeneratorType {
config.UuidGeneratorType = 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.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false)
config.ForgetPasswordRequireVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "forget_password_require_email_verify", false) config.ForgetPasswordRequireVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "forget_password_require_email_verify", false)
if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == "" { if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == InternalAvatarProvider {
config.AvatarProvider = "" config.AvatarProvider = InternalAvatarProvider
} else if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == GravatarProvider { } else if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == GravatarProvider {
config.AvatarProvider = GravatarProvider config.AvatarProvider = GravatarProvider
} else if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == "" {
config.AvatarProvider = ""
} else { } else {
return errs.ErrInvalidAvatarProvider return errs.ErrInvalidAvatarProvider
} }
+68
View File
@@ -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)
}
+11
View File
@@ -0,0 +1,11 @@
package storage
import (
"io"
)
// ObjectInStorage represents the object instance in the storage
type ObjectInStorage interface {
io.ReadCloser
io.Seeker
}
+9
View File
@@ -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
}
+56
View File
@@ -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)
}
+9
View File
@@ -7,6 +7,15 @@ import (
const gravatarUrlFormat = "https://www.gravatar.com/avatar/%s" 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 // GetGravatarUrl returns the Gravatar url according to the specified user email address
func GetGravatarUrl(email string) string { func GetGravatarUrl(email string) string {
email = strings.TrimSpace(email) email = strings.TrimSpace(email)
+10
View File
@@ -6,6 +6,16 @@ import (
"github.com/stretchr/testify/assert" "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) { func TestGetGravatarUrl(t *testing.T) {
// Reference: https://en.gravatar.com/site/implement/hash/ // Reference: https://en.gravatar.com/site/implement/hash/
expectedValue := "https://www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346" expectedValue := "https://www.gravatar.com/avatar/0bc83cb571cd1c50ba6f3e8a78ef1346"
+43
View File
@@ -3,9 +3,29 @@ package utils
import ( import (
"io" "io"
"os" "os"
"path/filepath"
"strings" "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 // ListFileNamesWithPrefixAndSuffix returns file name list which has specified prefix and suffix
func ListFileNamesWithPrefixAndSuffix(path string, prefix string, suffix string) []string { func ListFileNamesWithPrefixAndSuffix(path string, prefix string, suffix string) []string {
dir, err := os.Open(path) dir, err := os.Open(path)
@@ -69,6 +89,29 @@ func WriteFile(path string, data []byte) error {
return err 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 // IdentReader returns the original io reader
func IdentReader(encoding string, input io.Reader) (io.Reader, error) { func IdentReader(encoding string, input io.Reader) (io.Reader, error) {
return input, nil return input, nil
+143
View File
@@ -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)
}
+5
View File
@@ -0,0 +1,5 @@
const supportedImageExtensions = '.jpg,.jpeg,.png,.gif,.webp';
export default {
supportedImageExtensions: supportedImageExtensions
}
+32
View File
@@ -2,12 +2,14 @@ import axios from 'axios';
import apiConstants from '@/consts/api.js'; import apiConstants from '@/consts/api.js';
import userState from './userstate.js'; import userState from './userstate.js';
import { isBoolean } from './common.js';
import { import {
getGoogleMapAPIKey, getGoogleMapAPIKey,
getBaiduMapAK, getBaiduMapAK,
getAmapApplicationKey getAmapApplicationKey
} from './server_settings.js'; } from './server_settings.js';
import { getTimezoneOffsetMinutes } from './datetime.js'; import { getTimezoneOffsetMinutes } from './datetime.js';
import { generateRandomUUID } from './misc.js';
let needBlockRequest = false; let needBlockRequest = false;
let blockedRequests = []; let blockedRequests = [];
@@ -192,6 +194,14 @@ export default {
incomeAmountColor incomeAmountColor
}); });
}, },
updateAvatar: ({ avatarFile }) => {
return axios.postForm('v1/users/avatar/update.json', {
avatar: avatarFile
});
},
removeAvatar: () => {
return axios.post('v1/users/avatar/remove.json');
},
resendVerifyEmailByLoginedUser: () => { resendVerifyEmailByLoginedUser: () => {
return axios.post('v1/users/verify_email/resend.json'); return axios.post('v1/users/verify_email/resend.json');
}, },
@@ -552,5 +562,27 @@ export default {
}, },
generateAmapApiInternalProxyUrl: () => { generateAmapApiInternalProxyUrl: () => {
return `${window.location.origin}${apiConstants.baseAmapApiProxyUrlPath}`; 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('&');
}
} }
}; };
+11
View File
@@ -592,6 +592,7 @@ export default {
'not implemented': 'Not implemented', 'not implemented': 'Not implemented',
'system is busy': 'System is busy', 'system is busy': 'System is busy',
'not supported': 'Not supported', 'not supported': 'Not supported',
'image type not supported': 'Image type is not supported',
'database operation failed': 'Database operation failed', 'database operation failed': 'Database operation failed',
'SMTP server is not enabled': 'SMTP server is not enabled', 'SMTP server is not enabled': 'SMTP server is not enabled',
'incomplete or incorrect submission': 'Incomplete or incorrect submission', 'incomplete or incorrect submission': 'Incomplete or incorrect submission',
@@ -624,6 +625,9 @@ export default {
'email validation not allowed': 'Email validation is not allowed', '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', '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', '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', 'unauthorized access': 'Unauthorized access',
'current token is invalid': 'Current token is invalid', 'current token is invalid': 'Current token is invalid',
'current token is expired': 'Current token is expired', 'current token is expired': 'Current token is expired',
@@ -1159,6 +1163,8 @@ export default {
'Basic Settings': 'Basic Settings', 'Basic Settings': 'Basic Settings',
'Security Settings': 'Security Settings', 'Security Settings': 'Security Settings',
'Two-Factor Authentication Settings': 'Two-Factor Authentication Settings', 'Two-Factor Authentication Settings': 'Two-Factor Authentication Settings',
'Update Avatar': 'Update Avatar',
'Remove Avatar': 'Remove Avatar',
'(Verified)': '(Verified)', '(Verified)': '(Verified)',
'(Not Verified)': '(Not Verified)', '(Not Verified)': '(Not Verified)',
'Email address is verified': 'Email address is 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', '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', 'Nothing has been modified': 'Nothing has been modified',
'Your profile has been successfully updated': 'Your profile has been successfully updated', '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', '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.', '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', 'Data Management': 'Data Management',
+11
View File
@@ -592,6 +592,7 @@ export default {
'not implemented': '未实现', 'not implemented': '未实现',
'system is busy': '系统繁忙', 'system is busy': '系统繁忙',
'not supported': '不支持', 'not supported': '不支持',
'image type not supported': '图片类型不支持',
'database operation failed': '数据库操作失败', 'database operation failed': '数据库操作失败',
'SMTP server is not enabled': 'SMTP 服务器没有启用', 'SMTP server is not enabled': 'SMTP 服务器没有启用',
'incomplete or incorrect submission': '提交不完整或不正确', 'incomplete or incorrect submission': '提交不完整或不正确',
@@ -624,6 +625,9 @@ export default {
'email validation not allowed': '不允许邮箱验证', 'email validation 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': '不能把隐藏账户设置为默认账户', 'user default account is hidden': '不能把隐藏账户设置为默认账户',
'no user avatar': '没有用户头像文件',
'user avatar is empty': '用户头像文件为空',
'user avatar not exists': '用户头像不存在',
'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': '更新头像',
'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': '无法更新用户头像',
'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': '无法更新用户信息', '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': '数据管理',
+56 -1
View File
@@ -18,7 +18,7 @@ export const useUserStore = defineStore('user', {
}, },
currentUserAvatar(state) { currentUserAvatar(state) {
const userInfo = state.currentUserBasicInfo || {}; const userInfo = state.currentUserBasicInfo || {};
return userInfo.avatar || null; return state.getUserAvatarUrl(userInfo, false);
}, },
currentUserDefaultAccountId(state) { currentUserDefaultAccountId(state) {
const userInfo = state.currentUserBasicInfo || {}; 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() { getUserDataStatistics() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
services.getUserDataStatistics().then(response => { 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);
}
} }
}); });
@@ -8,15 +8,21 @@
</template> </template>
<v-card-text class="d-flex"> <v-card-text class="d-flex">
<v-avatar rounded="lg" color="primary" variant="tonal" size="100" class="me-4"> <v-avatar rounded="lg" color="primary" variant="tonal" size="100" class="me-4" :class="{ 'cursor-pointer': oldProfile.avatarProvider === 'internal' }">
<v-img :src="oldProfile.avatar" v-if="oldProfile.avatar"> <v-img :src="currentUserAvatar" v-if="currentUserAvatar">
<template #placeholder> <template #placeholder>
<div class="d-flex align-center justify-center fill-height"> <div class="d-flex align-center justify-center fill-height">
<v-icon size="48" :icon="icons.user"/> <v-icon size="48" :icon="icons.user"/>
</div> </div>
</template> </template>
</v-img> </v-img>
<v-icon size="48" :icon="icons.user" v-else-if="!oldProfile.avatar"/> <v-icon size="48" :icon="icons.user" v-else-if="!currentUserAvatar"/>
<v-menu activator="parent" width="200" location="bottom" offset="14px" v-if="oldProfile.avatarProvider === 'internal'">
<v-list>
<v-list-item :disabled="saving" :title="$t('Update Avatar')" @click="showOpenAvatarDialog"></v-list-item>
<v-list-item :disabled="!currentUserAvatar || saving" :title="$t('Remove Avatar')" @click="removeAvatar"></v-list-item>
</v-list>
</v-menu>
</v-avatar> </v-avatar>
<div class="d-flex flex-column justify-center gap-3"> <div class="d-flex flex-column justify-center gap-3">
<div class="d-flex text-body-1"> <div class="d-flex text-body-1">
@@ -301,6 +307,7 @@
<confirm-dialog ref="confirmDialog"/> <confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" /> <snack-bar ref="snackbar" />
<input ref="avatarInput" type="file" style="display: none" :accept="supportedImageExtensions" @change="updateAvatar($event)" />
</template> </template>
<script> <script>
@@ -312,7 +319,9 @@ import { useAccountsStore } from '@/stores/account.js';
import { useOverviewStore } from '@/stores/overview.js'; import { useOverviewStore } from '@/stores/overview.js';
import datetimeConstants from '@/consts/datetime.js'; import datetimeConstants from '@/consts/datetime.js';
import fileConstants from '@/consts/file.js';
import { getNameByKeyValue } from '@/lib/common.js'; import { getNameByKeyValue } from '@/lib/common.js';
import { generateRandomUUID } from '@/lib/misc.js';
import { getCategorizedAccounts } from '@/lib/account.js'; import { getCategorizedAccounts } from '@/lib/account.js';
import { isUserVerifyEmailEnabled } from '@/lib/server_settings.js'; import { isUserVerifyEmailEnabled } from '@/lib/server_settings.js';
import { setExpenseAndIncomeAmountColor } from '@/lib/ui.js'; import { setExpenseAndIncomeAmountColor } from '@/lib/ui.js';
@@ -366,6 +375,7 @@ export default {
expenseAmountColor: 0, expenseAmountColor: 0,
incomeAmountColor: 0 incomeAmountColor: 0
}, },
avatarNoCacheId: '',
emailVerified: false, emailVerified: false,
loading: true, loading: true,
resending: false, resending: false,
@@ -428,6 +438,12 @@ export default {
allTransactionEditScopeTypes() { allTransactionEditScopeTypes() {
return this.$locale.getAllTransactionEditScopeTypes(); return this.$locale.getAllTransactionEditScopeTypes();
}, },
supportedImageExtensions() {
return fileConstants.supportedImageExtensions;
},
currentUserAvatar() {
return this.userStore.getUserAvatarUrl(this.oldProfile, this.avatarNoCacheId);
},
isUserVerifyEmailEnabled() { isUserVerifyEmailEnabled() {
return isUserVerifyEmailEnabled(); return isUserVerifyEmailEnabled();
}, },
@@ -558,6 +574,61 @@ export default {
} }
}); });
}, },
showOpenAvatarDialog() {
this.$refs.avatarInput.click();
},
updateAvatar(event) {
if (!event || !event.target || !event.target.files || !event.target.files.length) {
return;
}
const self = this;
const avatarFile = event.target.files[0];
event.target.value = null;
self.saving = true;
self.userStore.updateUserAvatar({ avatarFile }).then(response => {
self.saving = false;
if (response) {
self.avatarNoCacheId = generateRandomUUID();
self.setCurrentUserProfile(response);
}
self.$refs.snackbar.showMessage('Your avatar has been successfully updated');
}).catch(error => {
self.saving = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
removeAvatar() {
const self = this;
self.$refs.confirmDialog.open('Are you sure you want to remove avatar?').then(() => {
self.saving = true;
self.userStore.removeUserAvatar().then(response => {
self.saving = false;
if (response) {
self.setCurrentUserProfile(response);
}
self.$refs.snackbar.showMessage('Your profile has been successfully updated');
}).catch(error => {
self.saving = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
});
},
reset() { reset() {
this.setCurrentUserProfile(this.oldProfile); this.setCurrentUserProfile(this.oldProfile);
}, },