supports local file system object storage and use it as the default avatar provider
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+14
-12
@@ -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")
|
||||
)
|
||||
|
||||
+7
-6
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+39
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// ObjectInStorage represents the object instance in the storage
|
||||
type ObjectInStorage interface {
|
||||
io.ReadCloser
|
||||
io.Seeker
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const supportedImageExtensions = '.jpg,.jpeg,.png,.gif,.webp';
|
||||
|
||||
export default {
|
||||
supportedImageExtensions: supportedImageExtensions
|
||||
}
|
||||
@@ -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('&');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '数据管理',
|
||||
|
||||
+56
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,15 +8,21 @@
|
||||
</template>
|
||||
|
||||
<v-card-text class="d-flex">
|
||||
<v-avatar rounded="lg" color="primary" variant="tonal" size="100" class="me-4">
|
||||
<v-img :src="oldProfile.avatar" v-if="oldProfile.avatar">
|
||||
<v-avatar rounded="lg" color="primary" variant="tonal" size="100" class="me-4" :class="{ 'cursor-pointer': oldProfile.avatarProvider === 'internal' }">
|
||||
<v-img :src="currentUserAvatar" v-if="currentUserAvatar">
|
||||
<template #placeholder>
|
||||
<div class="d-flex align-center justify-center fill-height">
|
||||
<v-icon size="48" :icon="icons.user"/>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
<div class="d-flex flex-column justify-center gap-3">
|
||||
<div class="d-flex text-body-1">
|
||||
@@ -301,6 +307,7 @@
|
||||
|
||||
<confirm-dialog ref="confirmDialog"/>
|
||||
<snack-bar ref="snackbar" />
|
||||
<input ref="avatarInput" type="file" style="display: none" :accept="supportedImageExtensions" @change="updateAvatar($event)" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -312,7 +319,9 @@ import { useAccountsStore } from '@/stores/account.js';
|
||||
import { useOverviewStore } from '@/stores/overview.js';
|
||||
|
||||
import datetimeConstants from '@/consts/datetime.js';
|
||||
import fileConstants from '@/consts/file.js';
|
||||
import { getNameByKeyValue } from '@/lib/common.js';
|
||||
import { generateRandomUUID } from '@/lib/misc.js';
|
||||
import { getCategorizedAccounts } from '@/lib/account.js';
|
||||
import { isUserVerifyEmailEnabled } from '@/lib/server_settings.js';
|
||||
import { setExpenseAndIncomeAmountColor } from '@/lib/ui.js';
|
||||
@@ -366,6 +375,7 @@ export default {
|
||||
expenseAmountColor: 0,
|
||||
incomeAmountColor: 0
|
||||
},
|
||||
avatarNoCacheId: '',
|
||||
emailVerified: false,
|
||||
loading: true,
|
||||
resending: false,
|
||||
@@ -428,6 +438,12 @@ export default {
|
||||
allTransactionEditScopeTypes() {
|
||||
return this.$locale.getAllTransactionEditScopeTypes();
|
||||
},
|
||||
supportedImageExtensions() {
|
||||
return fileConstants.supportedImageExtensions;
|
||||
},
|
||||
currentUserAvatar() {
|
||||
return this.userStore.getUserAvatarUrl(this.oldProfile, this.avatarNoCacheId);
|
||||
},
|
||||
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() {
|
||||
this.setCurrentUserProfile(this.oldProfile);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user