From 847349dcbd3f0576081b25a6de8e8eb576055349 Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sun, 7 Jul 2024 21:28:07 +0800 Subject: [PATCH] support using duplicate checker to prevent duplicate submissions for new transaction record --- cmd/initializer.go | 10 ++++ conf/ezbookkeeping.ini | 11 +++++ go.mod | 1 + go.sum | 2 + pkg/api/accounts.go | 40 ++++++++++++++++ pkg/api/transaction_categories.go | 26 +++++++++++ pkg/api/transactions.go | 25 ++++++++++ pkg/duplicatechecker/duplicate_checker.go | 7 +++ .../duplicate_checker_container.go | 38 +++++++++++++++ .../duplicate_checker_type.go | 12 +++++ .../in_memory_duplicate_checker.go | 43 +++++++++++++++++ pkg/errs/setting.go | 1 + pkg/models/account.go | 19 ++++---- pkg/models/transaction.go | 1 + pkg/models/transaction_category.go | 13 +++--- pkg/settings/setting.go | 46 +++++++++++++++++++ src/lib/misc.js | 26 ++++++++++- src/lib/services.js | 15 +++--- src/stores/account.js | 6 ++- src/stores/transaction.js | 6 ++- src/stores/transactionCategory.js | 6 ++- .../accounts/list/dialogs/EditDialog.vue | 6 ++- .../categories/list/dialogs/EditDialog.vue | 6 ++- .../transactions/list/dialogs/EditDialog.vue | 9 +++- src/views/mobile/accounts/EditPage.vue | 6 ++- src/views/mobile/categories/EditPage.vue | 6 ++- src/views/mobile/transactions/EditPage.vue | 9 +++- third-party-dependencies.json | 6 +++ 28 files changed, 371 insertions(+), 31 deletions(-) create mode 100644 pkg/duplicatechecker/duplicate_checker.go create mode 100644 pkg/duplicatechecker/duplicate_checker_container.go create mode 100644 pkg/duplicatechecker/duplicate_checker_type.go create mode 100644 pkg/duplicatechecker/in_memory_duplicate_checker.go diff --git a/cmd/initializer.go b/cmd/initializer.go index 4d9ed63b..a0298d67 100644 --- a/cmd/initializer.go +++ b/cmd/initializer.go @@ -7,6 +7,7 @@ import ( "github.com/urfave/cli/v2" "github.com/mayswind/ezbookkeeping/pkg/datastore" + "github.com/mayswind/ezbookkeeping/pkg/duplicatechecker" "github.com/mayswind/ezbookkeeping/pkg/exchangerates" "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/mail" @@ -88,6 +89,15 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) { return nil, err } + err = duplicatechecker.InitializeDuplicateChecker(config) + + if err != nil { + if !isDisableBootLog { + log.BootErrorf("[initializer.initializeSystem] initializes duplicate checker failed, because %s", err.Error()) + } + return nil, err + } + err = mail.InitializeMailer(config) if err != nil { diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index 06d9441a..ad0317d6 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -99,6 +99,17 @@ generator_type = internal # For "internal" only, each server must have unique id (0 - 255) server_id = 0 +[duplicate_checker] +# Duplicate checker type, supports "in_memory" currently +checker_type = in_memory + +# For "in_memory" only, cleanup expired data interval seconds (0 - 4294967295), default is 60 (1 minutes) +cleanup_interval = 60 + +# The minimum interval seconds between duplicate submissions on the same page (exiting and re-entering the page is considered as a new session) +# Set to 0 to disable duplicate checker for new data submissions, default is 300 (5 minutes) +duplicate_submissions_interval = 300 + [security] # Used for signing, you must change it to keep your user data safe before you first run ezBookkeeping secret_key = diff --git a/go.mod b/go.mod index e431e314..adcfa06c 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.22 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.4.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index 1df4c996..cd85f45f 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/pkg/api/accounts.go b/pkg/api/accounts.go index 8f8adf67..625f42e8 100644 --- a/pkg/api/accounts.go +++ b/pkg/api/accounts.go @@ -4,10 +4,13 @@ import ( "sort" "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/duplicatechecker" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" "github.com/mayswind/ezbookkeeping/pkg/validators" ) @@ -203,6 +206,42 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) { mainAccount := a.createNewAccountModel(uid, &accountCreateReq, maxOrderId+1) childrenAccounts := a.createSubAccountModels(uid, &accountCreateReq) + if settings.Container.Current.EnableDuplicateSubmissionsCheck && accountCreateReq.ClientSessionId != "" { + found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId) + + if found { + log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] another account \"id:%s\" has been created for user \"uid:%d\"", remark, uid) + accountId, err := utils.StringToInt64(remark) + + if err == nil { + accountAndSubAccounts, err := a.accounts.GetAccountAndSubAccountsByAccountId(c, uid, accountId) + + if err != nil { + log.ErrorfWithRequestId(c, "[accounts.AccountCreateHandler] failed to get existed account \"id:%d\" for user \"uid:%d\", because %s", accountId, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts) + mainAccount, exists := accountMap[accountId] + + if !exists { + return nil, errs.ErrOperationFailed + } + + accountInfoResp := mainAccount.ToAccountInfoResponse() + + for i := 0; i < len(accountAndSubAccounts); i++ { + if accountAndSubAccounts[i].ParentAccountId == mainAccount.AccountId { + subAccountResp := accountAndSubAccounts[i].ToAccountInfoResponse() + accountInfoResp.SubAccounts = append(accountInfoResp.SubAccounts, subAccountResp) + } + } + + return accountInfoResp, nil + } + } + } + err = a.accounts.CreateAccounts(c, mainAccount, childrenAccounts, utcOffset) if err != nil { @@ -212,6 +251,7 @@ func (a *AccountsApi) AccountCreateHandler(c *core.Context) (any, *errs.Error) { log.InfofWithRequestId(c, "[accounts.AccountCreateHandler] user \"uid:%d\" has created a new account \"id:%d\" successfully", uid, mainAccount.AccountId) + duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT, uid, accountCreateReq.ClientSessionId, utils.Int64ToString(mainAccount.AccountId)) accountInfoResp := mainAccount.ToAccountInfoResponse() if len(childrenAccounts) > 0 { diff --git a/pkg/api/transaction_categories.go b/pkg/api/transaction_categories.go index c2e57152..9631d735 100644 --- a/pkg/api/transaction_categories.go +++ b/pkg/api/transaction_categories.go @@ -6,10 +6,13 @@ import ( "github.com/gin-gonic/gin/binding" "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/duplicatechecker" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" + "github.com/mayswind/ezbookkeeping/pkg/utils" ) // TransactionCategoriesApi represents transaction category api @@ -119,6 +122,28 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any, category := a.createNewCategoryModel(uid, &categoryCreateReq, maxOrderId+1) + if settings.Container.Current.EnableDuplicateSubmissionsCheck && categoryCreateReq.ClientSessionId != "" { + found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId) + + if found { + log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] another category \"id:%s\" has been created for user \"uid:%d\"", remark, uid) + categoryId, err := utils.StringToInt64(remark) + + if err == nil { + category, err = a.categories.GetCategoryByCategoryId(c, uid, categoryId) + + if err != nil { + log.ErrorfWithRequestId(c, "[transaction_categories.CategoryCreateHandler] failed to get existed category \"id:%d\" for user \"uid:%d\", because %s", categoryId, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + categoryResp := category.ToTransactionCategoryInfoResponse() + + return categoryResp, nil + } + } + } + err = a.categories.CreateCategory(c, category) if err != nil { @@ -128,6 +153,7 @@ func (a *TransactionCategoriesApi) CategoryCreateHandler(c *core.Context) (any, log.InfofWithRequestId(c, "[transaction_categories.CategoryCreateHandler] user \"uid:%d\" has created a new category \"id:%d\" successfully", uid, category.CategoryId) + duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_CATEGORY, uid, categoryCreateReq.ClientSessionId, utils.Int64ToString(category.CategoryId)) categoryResp := category.ToTransactionCategoryInfoResponse() return categoryResp, nil diff --git a/pkg/api/transactions.go b/pkg/api/transactions.go index 75c7ca20..88806d1c 100644 --- a/pkg/api/transactions.go +++ b/pkg/api/transactions.go @@ -7,10 +7,12 @@ import ( orderedmap "github.com/wk8/go-ordered-map/v2" "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/duplicatechecker" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/models" "github.com/mayswind/ezbookkeeping/pkg/services" + "github.com/mayswind/ezbookkeeping/pkg/settings" "github.com/mayswind/ezbookkeeping/pkg/utils" ) @@ -625,6 +627,28 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.Context) (any, *errs. return nil, errs.ErrCannotCreateTransactionWithThisTransactionTime } + if settings.Container.Current.EnableDuplicateSubmissionsCheck && transactionCreateReq.ClientSessionId != "" { + found, remark := duplicatechecker.Container.Get(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION, uid, transactionCreateReq.ClientSessionId) + + if found { + log.InfofWithRequestId(c, "[transactions.TransactionCreateHandler] another transaction \"id:%s\" has been created for user \"uid:%d\"", remark, uid) + transactionId, err := utils.StringToInt64(remark) + + if err == nil { + transaction, err = a.transactions.GetTransactionByTransactionId(c, uid, transactionId) + + if err != nil { + log.ErrorfWithRequestId(c, "[transactions.TransactionCreateHandler] failed to get existed transaction \"id:%d\" for user \"uid:%d\", because %s", transactionId, uid, err.Error()) + return nil, errs.Or(err, errs.ErrOperationFailed) + } + + transactionResp := transaction.ToTransactionInfoResponse(tagIds, transactionEditable) + + return transactionResp, nil + } + } + } + err = a.transactions.CreateTransaction(c, transaction, tagIds) if err != nil { @@ -634,6 +658,7 @@ func (a *TransactionsApi) TransactionCreateHandler(c *core.Context) (any, *errs. log.InfofWithRequestId(c, "[transactions.TransactionCreateHandler] user \"uid:%d\" has created a new transaction \"id:%d\" successfully", uid, transaction.TransactionId) + duplicatechecker.Container.Set(duplicatechecker.DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION, uid, transactionCreateReq.ClientSessionId, utils.Int64ToString(transaction.TransactionId)) transactionResp := transaction.ToTransactionInfoResponse(tagIds, transactionEditable) return transactionResp, nil diff --git a/pkg/duplicatechecker/duplicate_checker.go b/pkg/duplicatechecker/duplicate_checker.go new file mode 100644 index 00000000..f9c5b760 --- /dev/null +++ b/pkg/duplicatechecker/duplicate_checker.go @@ -0,0 +1,7 @@ +package duplicatechecker + +// DuplicateChecker is common duplicate checker interface +type DuplicateChecker interface { + Get(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) + Set(checkerType DuplicateCheckerType, uid int64, identification string, remark string) +} diff --git a/pkg/duplicatechecker/duplicate_checker_container.go b/pkg/duplicatechecker/duplicate_checker_container.go new file mode 100644 index 00000000..8634b0e8 --- /dev/null +++ b/pkg/duplicatechecker/duplicate_checker_container.go @@ -0,0 +1,38 @@ +package duplicatechecker + +import ( + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/settings" +) + +// DuplicateCheckerContainer contains the current duplicate checker +type DuplicateCheckerContainer struct { + Current DuplicateChecker +} + +// Initialize a duplicate checker container singleton instance +var ( + Container = &DuplicateCheckerContainer{} +) + +// InitializeDuplicateChecker initializes the current duplicate checker according to the config +func InitializeDuplicateChecker(config *settings.Config) error { + if config.DuplicateCheckerType == settings.InMemoryDuplicateCheckerType { + checker, err := NewInMemoryDuplicateChecker(config) + Container.Current = checker + + return err + } + + return errs.ErrInvalidDuplicateCheckerType +} + +// Get returns whether the same submission has been processed and related remark by the current duplicate checker +func (c *DuplicateCheckerContainer) Get(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) { + return c.Current.Get(checkerType, uid, identification) +} + +// Set saves the identification and remark to in-memory cache by the current duplicate checker +func (c *DuplicateCheckerContainer) Set(checkerType DuplicateCheckerType, uid int64, identification string, remark string) { + c.Current.Set(checkerType, uid, identification, remark) +} diff --git a/pkg/duplicatechecker/duplicate_checker_type.go b/pkg/duplicatechecker/duplicate_checker_type.go new file mode 100644 index 00000000..693ccf86 --- /dev/null +++ b/pkg/duplicatechecker/duplicate_checker_type.go @@ -0,0 +1,12 @@ +package duplicatechecker + +// DuplicateCheckerType represents duplicate checker type +type DuplicateCheckerType uint8 + +// Types of uuid +const ( + DUPLICATE_CHECKER_TYPE_DEFAULT DuplicateCheckerType = 0 + DUPLICATE_CHECKER_TYPE_NEW_ACCOUNT DuplicateCheckerType = 1 + DUPLICATE_CHECKER_TYPE_NEW_CATEGORY DuplicateCheckerType = 2 + DUPLICATE_CHECKER_TYPE_NEW_TRANSACTION DuplicateCheckerType = 3 +) diff --git a/pkg/duplicatechecker/in_memory_duplicate_checker.go b/pkg/duplicatechecker/in_memory_duplicate_checker.go new file mode 100644 index 00000000..7207da83 --- /dev/null +++ b/pkg/duplicatechecker/in_memory_duplicate_checker.go @@ -0,0 +1,43 @@ +package duplicatechecker + +import ( + "fmt" + + "github.com/patrickmn/go-cache" + + "github.com/mayswind/ezbookkeeping/pkg/settings" +) + +// InMemoryDuplicateChecker represents in-memory duplicate checker +type InMemoryDuplicateChecker struct { + cache *cache.Cache +} + +// NewInMemoryDuplicateChecker returns a new in-memory duplicate checker +func NewInMemoryDuplicateChecker(config *settings.Config) (*InMemoryDuplicateChecker, error) { + checker := &InMemoryDuplicateChecker{ + cache: cache.New(config.DuplicateSubmissionsIntervalDuration, config.InMemoryDuplicateCheckerCleanupIntervalDuration), + } + + return checker, nil +} + +// Get returns whether the same submission has been processed and related remark +func (c *InMemoryDuplicateChecker) Get(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string) { + existedRemark, found := c.cache.Get(c.getCacheKey(checkerType, uid, identification)) + + if found { + return true, existedRemark.(string) + } + + return false, "" +} + +// Set saves the identification and remark to in-memory cache +func (c *InMemoryDuplicateChecker) Set(checkerType DuplicateCheckerType, uid int64, identification string, remark string) { + c.cache.Set(c.getCacheKey(checkerType, uid, identification), remark, cache.DefaultExpiration) +} + +func (c *InMemoryDuplicateChecker) getCacheKey(checkerType DuplicateCheckerType, uid int64, identification string) string { + return fmt.Sprintf("%d|%d|%s", checkerType, uid, identification) +} diff --git a/pkg/errs/setting.go b/pkg/errs/setting.go index f460f531..ac316e39 100644 --- a/pkg/errs/setting.go +++ b/pkg/errs/setting.go @@ -11,4 +11,5 @@ var ( ErrInvalidExchangeRatesDataSource = NewSystemError(SystemSubcategorySetting, 4, http.StatusInternalServerError, "invalid exchange rates data source") ErrInvalidMapProvider = NewSystemError(SystemSubcategorySetting, 5, http.StatusInternalServerError, "invalid map provider") ErrInvalidAmapSecurityVerificationMethod = NewSystemError(SystemSubcategorySetting, 6, http.StatusInternalServerError, "invalid amap security verification method") + ErrInvalidDuplicateCheckerType = NewSystemError(SystemSubcategorySetting, 7, http.StatusInternalServerError, "invalid duplicate checker type") ) diff --git a/pkg/models/account.go b/pkg/models/account.go index d3af4cab..fe54c482 100644 --- a/pkg/models/account.go +++ b/pkg/models/account.go @@ -69,15 +69,16 @@ type Account struct { // AccountCreateRequest represents all parameters of account creation request type AccountCreateRequest struct { - Name string `json:"name" binding:"required,notBlank,max=32"` - Category AccountCategory `json:"category" binding:"required"` - Type AccountType `json:"type" binding:"required"` - Icon int64 `json:"icon,string" binding:"required,min=1"` - Color string `json:"color" binding:"required,len=6,validHexRGBColor"` - Currency string `json:"currency" binding:"required,len=3,validCurrency"` - Balance int64 `json:"balance"` - Comment string `json:"comment" binding:"max=255"` - SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"` + Name string `json:"name" binding:"required,notBlank,max=32"` + Category AccountCategory `json:"category" binding:"required"` + Type AccountType `json:"type" binding:"required"` + Icon int64 `json:"icon,string" binding:"required,min=1"` + Color string `json:"color" binding:"required,len=6,validHexRGBColor"` + Currency string `json:"currency" binding:"required,len=3,validCurrency"` + Balance int64 `json:"balance"` + Comment string `json:"comment" binding:"max=255"` + SubAccounts []*AccountCreateRequest `json:"subAccounts" binding:"omitempty"` + ClientSessionId string `json:"clientSessionId"` } // AccountModifyRequest represents all parameters of account modification request diff --git a/pkg/models/transaction.go b/pkg/models/transaction.go index 8c6ff7f6..add9e922 100644 --- a/pkg/models/transaction.go +++ b/pkg/models/transaction.go @@ -74,6 +74,7 @@ type TransactionCreateRequest struct { TagIds []string `json:"tagIds"` Comment string `json:"comment" binding:"max=255"` GeoLocation *TransactionGeoLocationRequest `json:"geoLocation" binding:"omitempty"` + ClientSessionId string `json:"clientSessionId"` } // TransactionModifyRequest represents all parameters of transaction modification request diff --git a/pkg/models/transaction_category.go b/pkg/models/transaction_category.go index 1e3f0999..fedca128 100644 --- a/pkg/models/transaction_category.go +++ b/pkg/models/transaction_category.go @@ -44,12 +44,13 @@ type TransactionCategoryGetRequest struct { // TransactionCategoryCreateRequest represents all parameters of single transaction category creation request type TransactionCategoryCreateRequest struct { - Name string `json:"name" binding:"required,notBlank,max=32"` - Type TransactionCategoryType `json:"type" binding:"required"` - ParentId int64 `json:"parentId,string" binding:"min=0"` - Icon int64 `json:"icon,string" binding:"min=1"` - Color string `json:"color" binding:"required,len=6,validHexRGBColor"` - Comment string `json:"comment" binding:"max=255"` + Name string `json:"name" binding:"required,notBlank,max=32"` + Type TransactionCategoryType `json:"type" binding:"required"` + ParentId int64 `json:"parentId,string" binding:"min=0"` + Icon int64 `json:"icon,string" binding:"min=1"` + Color string `json:"color" binding:"required,len=6,validHexRGBColor"` + Comment string `json:"comment" binding:"max=255"` + ClientSessionId string `json:"clientSessionId"` } // TransactionCategoryCreateBatchRequest represents all parameters of transaction category batch creation request diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 25711802..0c3f02d7 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -62,6 +62,11 @@ const ( InternalUuidGeneratorType string = "internal" ) +// Duplicate checker types +const ( + InMemoryDuplicateCheckerType string = "in_memory" +) + // User avatar provider types const ( GravatarProvider string = "gravatar" @@ -115,6 +120,9 @@ const ( defaultLogMode string = "console" defaultLoglevel Level = LOGLEVEL_INFO + defaultInMemoryDuplicateCheckerCleanupInterval uint32 = 60 // 1 minutes + defaultDuplicateSubmissionsInterval uint32 = 300 // 5 minutes + defaultSecretKey string = "ezbookkeeping" defaultTokenExpiredTime uint32 = 604800 // 7 days defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes @@ -196,6 +204,14 @@ type Config struct { UuidGeneratorType string UuidServerId uint8 + // Duplicate Checker + DuplicateCheckerType string + InMemoryDuplicateCheckerCleanupInterval uint32 + InMemoryDuplicateCheckerCleanupIntervalDuration time.Duration + EnableDuplicateSubmissionsCheck bool + DuplicateSubmissionsInterval uint32 + DuplicateSubmissionsIntervalDuration time.Duration + // Secret SecretKeyNoSet bool SecretKey string @@ -297,6 +313,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) { return nil, err } + err = loadDuplicateCheckerConfiguration(config, cfgFile, "duplicate_checker") + + if err != nil { + return nil, err + } + err = loadSecurityConfiguration(config, cfgFile, "security") if err != nil { @@ -487,6 +509,30 @@ func loadUuidConfiguration(config *Config, configFile *ini.File, sectionName str return nil } +func loadDuplicateCheckerConfiguration(config *Config, configFile *ini.File, sectionName string) error { + if getConfigItemStringValue(configFile, sectionName, "checker_type") == InMemoryDuplicateCheckerType { + config.DuplicateCheckerType = InMemoryDuplicateCheckerType + } else { + return errs.ErrInvalidDuplicateCheckerType + } + + config.InMemoryDuplicateCheckerCleanupInterval = getConfigItemUint32Value(configFile, sectionName, "cleanup_interval", defaultInMemoryDuplicateCheckerCleanupInterval) + config.InMemoryDuplicateCheckerCleanupIntervalDuration = time.Duration(config.InMemoryDuplicateCheckerCleanupInterval) * time.Second + + duplicateSubmissionsInterval := getConfigItemUint32Value(configFile, sectionName, "duplicate_submissions_interval", defaultDuplicateSubmissionsInterval) + + config.EnableDuplicateSubmissionsCheck = duplicateSubmissionsInterval > 0 + + if duplicateSubmissionsInterval < 1 { + duplicateSubmissionsInterval = defaultDuplicateSubmissionsInterval + } + + config.DuplicateSubmissionsInterval = duplicateSubmissionsInterval + config.DuplicateSubmissionsIntervalDuration = time.Duration(config.DuplicateSubmissionsInterval) * time.Second + + return nil +} + func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName string) error { config.SecretKeyNoSet = !getConfigItemIsSet(configFile, sectionName, "secret_key") config.SecretKey = getConfigItemStringValue(configFile, sectionName, "secret_key", defaultSecretKey) diff --git a/src/lib/misc.js b/src/lib/misc.js index ea1076f8..98e40499 100644 --- a/src/lib/misc.js +++ b/src/lib/misc.js @@ -2,6 +2,8 @@ import Clipboard from 'clipboard'; import CryptoJS from 'crypto-js'; import uaParser from 'ua-parser-js'; +import { base64encode } from './common.js'; + export function asyncLoadAssets(type, assetUrl) { return new Promise(function (resolve, reject) { let addElement = false; @@ -71,10 +73,32 @@ export function asyncLoadAssets(type, assetUrl) { } export function generateRandomString() { - const baseString = 'ebk_' + Math.round(new Date().getTime() / 1000) + '_' + Math.random(); + let baseString = 'ebk_' + new Date().getTime(); + + if (crypto && crypto.getRandomValues) { + const randoms = new Uint8Array(256); + crypto.getRandomValues(randoms); + baseString += '_' + base64encode(randoms); + } else { + baseString += '_' + Math.random(); + } + return CryptoJS.SHA256(baseString).toString(); } +export function generateRandomUUID() { + const randomString = generateRandomString(); + + // convert hash string to UUID Version 8 + const uuid = randomString.substring(0, 8) + '-' + + randomString.substring(8, 12) + '-' + + '8' + randomString.substring(13, 16) + '-' + + (0x8 | (parseInt(randomString.charAt(16), 16) & 0x3)).toString(16) + randomString.substring(17, 20) + '-' + + randomString.substring(20, 32); + + return uuid; +} + export function parseUserAgent(ua) { const uaParseRet = uaParser(ua); diff --git a/src/lib/services.js b/src/lib/services.js index 43a2d77c..dcbc7e6b 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -238,7 +238,7 @@ export default { getAccount: ({ id }) => { return axios.get('v1/accounts/get.json?id=' + id); }, - addAccount: ({ category, type, name, icon, color, currency, balance, comment, subAccounts }) => { + addAccount: ({ category, type, name, icon, color, currency, balance, comment, subAccounts, clientSessionId }) => { return axios.post('v1/accounts/add.json', { category, type, @@ -248,7 +248,8 @@ export default { currency, balance, comment, - subAccounts + subAccounts, + clientSessionId }); }, modifyAccount: ({ id, category, name, icon, color, comment, hidden, subAccounts }) => { @@ -383,7 +384,7 @@ export default { getTransaction: ({ id }) => { return axios.get(`v1/transactions/get.json?id=${id}&trim_account=true&trim_category=true&trim_tag=true`); }, - addTransaction: ({ type, categoryId, time, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, hideAmount, tagIds, comment, geoLocation, utcOffset }) => { + addTransaction: ({ type, categoryId, time, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, hideAmount, tagIds, comment, geoLocation, utcOffset, clientSessionId }) => { return axios.post('v1/transactions/add.json', { type, categoryId, @@ -396,7 +397,8 @@ export default { tagIds, comment, geoLocation, - utcOffset + utcOffset, + clientSessionId }); }, modifyTransaction: ({ id, type, categoryId, time, sourceAccountId, destinationAccountId, sourceAmount, destinationAmount, hideAmount, tagIds, comment, geoLocation, utcOffset }) => { @@ -427,14 +429,15 @@ export default { getTransactionCategory: ({ id }) => { return axios.get('v1/transaction/categories/get.json?id=' + id); }, - addTransactionCategory: ({ name, type, parentId, icon, color, comment }) => { + addTransactionCategory: ({ name, type, parentId, icon, color, comment, clientSessionId }) => { return axios.post('v1/transaction/categories/add.json', { name, type, parentId, icon, color, - comment + comment, + clientSessionId }); }, addTransactionCategoryBatch: ({ categories }) => { diff --git a/src/stores/account.js b/src/stores/account.js index 70f598ca..dbf6ea93 100644 --- a/src/stores/account.js +++ b/src/stores/account.js @@ -736,7 +736,7 @@ export const useAccountsStore = defineStore('accounts', { }); }); }, - saveAccount({ account, subAccounts, isEdit }) { + saveAccount({ account, subAccounts, isEdit, clientSessionId }) { const self = this; const submitSubAccounts = []; @@ -776,6 +776,10 @@ export const useAccountsStore = defineStore('accounts', { subAccounts: account.type === accountConstants.allAccountTypes.SingleAccount ? null : submitSubAccounts, }; + if (clientSessionId) { + submitAccount.clientSessionId = clientSessionId; + } + if (isEdit) { submitAccount.id = account.id; submitAccount.hidden = !account.visible; diff --git a/src/stores/transaction.js b/src/stores/transaction.js index 67cf468b..7b25a3f6 100644 --- a/src/stores/transaction.js +++ b/src/stores/transaction.js @@ -745,7 +745,7 @@ export const useTransactionsStore = defineStore('transactions', { }); }); }, - saveTransaction({ transaction, defaultCurrency, isEdit }) { + saveTransaction({ transaction, defaultCurrency, isEdit, clientSessionId }) { const self = this; const settingsStore = useSettingsStore(); const exchangeRatesStore = useExchangeRatesStore(); @@ -764,6 +764,10 @@ export const useTransactionsStore = defineStore('transactions', { utcOffset: transaction.utcOffset }; + if (clientSessionId) { + submitTransaction.clientSessionId = clientSessionId; + } + if (transaction.type === transactionConstants.allTransactionTypes.Expense) { submitTransaction.categoryId = transaction.expenseCategory; } else if (transaction.type === transactionConstants.allTransactionTypes.Income) { diff --git a/src/stores/transactionCategory.js b/src/stores/transactionCategory.js index 6888e076..1de4b95d 100644 --- a/src/stores/transactionCategory.js +++ b/src/stores/transactionCategory.js @@ -257,7 +257,7 @@ export const useTransactionCategoriesStore = defineStore('transactionCategories' }); }); }, - saveCategory({ category, isEdit }) { + saveCategory({ category, isEdit, clientSessionId }) { const self = this; const submitCategory = { @@ -269,6 +269,10 @@ export const useTransactionCategoriesStore = defineStore('transactionCategories' comment: category.comment }; + if (clientSessionId) { + submitCategory.clientSessionId = clientSessionId; + } + if (isEdit) { submitCategory.id = category.id; submitCategory.hidden = !category.visible; diff --git a/src/views/desktop/accounts/list/dialogs/EditDialog.vue b/src/views/desktop/accounts/list/dialogs/EditDialog.vue index e16cd610..3255be4f 100644 --- a/src/views/desktop/accounts/list/dialogs/EditDialog.vue +++ b/src/views/desktop/accounts/list/dialogs/EditDialog.vue @@ -181,6 +181,7 @@ import accountConstants from '@/consts/account.js'; import iconConstants from '@/consts/icon.js'; import colorConstants from '@/consts/color.js'; import { isNumber } from '@/lib/common.js'; +import { generateRandomUUID } from '@/lib/misc.js'; import { setAccountModelByAnotherAccount, setAccountSuitableIcon @@ -208,6 +209,7 @@ export default { showState: false, activeTab: 'account', editAccountId: null, + clientSessionId: '', loading: false, account: newAccount, subAccounts: [], @@ -314,6 +316,7 @@ export default { } self.editAccountId = null; + self.clientSessionId = generateRandomUUID(); self.loading = false; } @@ -370,7 +373,8 @@ export default { self.accountsStore.saveAccount({ account: self.account, subAccounts: self.subAccounts, - isEdit: !!self.editAccountId + isEdit: !!self.editAccountId, + clientSessionId: self.clientSessionId }).then(() => { self.submitting = false; diff --git a/src/views/desktop/categories/list/dialogs/EditDialog.vue b/src/views/desktop/categories/list/dialogs/EditDialog.vue index 0a81c51d..4a230bb7 100644 --- a/src/views/desktop/categories/list/dialogs/EditDialog.vue +++ b/src/views/desktop/categories/list/dialogs/EditDialog.vue @@ -101,6 +101,7 @@ import { useTransactionCategoriesStore } from '@/stores/transactionCategory.js'; import categoryConstants from '@/consts/category.js'; import iconConstants from '@/consts/icon.js'; import colorConstants from '@/consts/color.js'; +import { generateRandomUUID } from '@/lib/misc.js'; import { setCategoryModelByAnotherCategory, allVisiblePrimaryTransactionCategoriesByType @@ -121,6 +122,7 @@ export default { return { showState: false, editCategoryId: null, + clientSessionId: '', loading: false, category: newTransactionCategory, submitting: false, @@ -220,6 +222,7 @@ export default { self.category.type = categoryType; self.category.parentId = options.parentId; + self.clientSessionId = generateRandomUUID(); self.loading = false; } @@ -242,7 +245,8 @@ export default { self.transactionCategoriesStore.saveCategory({ category: self.category, - isEdit: !!self.editCategoryId + isEdit: !!self.editCategoryId, + clientSessionId: self.clientSessionId }).then(() => { self.submitting = false; diff --git a/src/views/desktop/transactions/list/dialogs/EditDialog.vue b/src/views/desktop/transactions/list/dialogs/EditDialog.vue index 33bf298b..214e2ddd 100644 --- a/src/views/desktop/transactions/list/dialogs/EditDialog.vue +++ b/src/views/desktop/transactions/list/dialogs/EditDialog.vue @@ -338,6 +338,7 @@ import { getTimezoneOffsetMinutes, getCurrentUnixTime } from '@/lib/datetime.js'; +import { generateRandomUUID } from '@/lib/misc.js'; import { getFirstAvailableCategoryId } from '@/lib/category.js'; @@ -370,6 +371,7 @@ export default { activeTab: 'basicInfo', editTransactionId: null, originalTransactionEditable: false, + clientSessionId: '', loading: true, transaction: newTransaction, geoLocationStatus: null, @@ -642,6 +644,10 @@ export default { self.transaction.type = parseInt(options.type); } + if (self.mode === 'add') { + self.clientSessionId = generateRandomUUID(); + } + Promise.all(promises).then(function (responses) { if (self.editTransactionId && !responses[3]) { if (self.reject) { @@ -691,7 +697,8 @@ export default { self.transactionsStore.saveTransaction({ transaction: self.transaction, defaultCurrency: self.defaultCurrency, - isEdit: self.mode === 'edit' + isEdit: self.mode === 'edit', + clientSessionId: self.clientSessionId }).then(() => { self.submitting = false; diff --git a/src/views/mobile/accounts/EditPage.vue b/src/views/mobile/accounts/EditPage.vue index d5f9c4ce..18c4d960 100644 --- a/src/views/mobile/accounts/EditPage.vue +++ b/src/views/mobile/accounts/EditPage.vue @@ -428,6 +428,7 @@ import iconConstants from '@/consts/icon.js'; import colorConstants from '@/consts/color.js'; import transactionConstants from '@/consts/transaction.js'; import { getNameByKeyValue } from '@/lib/common.js'; +import { generateRandomUUID } from '@/lib/misc.js'; import { setAccountModelByAnotherAccount, setAccountSuitableIcon @@ -447,6 +448,7 @@ export default { return { editAccountId: null, + clientSessionId: '', loading: false, loadingError: null, account: newAccount, @@ -547,6 +549,7 @@ export default { } }); } else { + self.clientSessionId = generateRandomUUID(); self.loading = false; } }, @@ -614,7 +617,8 @@ export default { self.accountsStore.saveAccount({ account: self.account, subAccounts: self.subAccounts, - isEdit: !!self.editAccountId + isEdit: !!self.editAccountId, + clientSessionId: self.clientSessionId }).then(() => { self.submitting = false; self.$hideLoading(); diff --git a/src/views/mobile/categories/EditPage.vue b/src/views/mobile/categories/EditPage.vue index 66c4a86f..72e3b57e 100644 --- a/src/views/mobile/categories/EditPage.vue +++ b/src/views/mobile/categories/EditPage.vue @@ -153,6 +153,7 @@ import categoryConstants from '@/consts/category.js'; import iconConstants from '@/consts/icon.js'; import colorConstants from '@/consts/color.js'; import { getNameByKeyValue } from '@/lib/common.js'; +import { generateRandomUUID } from '@/lib/misc.js'; import { setCategoryModelByAnotherCategory, allVisiblePrimaryTransactionCategoriesByType @@ -172,6 +173,7 @@ export default { return { editCategoryId: null, + clientSessionId: '', loading: false, loadingError: null, category: newTransactionCategory, @@ -257,6 +259,7 @@ export default { return; } + self.clientSessionId = generateRandomUUID(); self.loading = false; } }, @@ -280,7 +283,8 @@ export default { self.transactionCategoriesStore.saveCategory({ category: self.category, - isEdit: !!self.editCategoryId + isEdit: !!self.editCategoryId, + clientSessionId: self.clientSessionId }).then(() => { self.submitting = false; self.$hideLoading(); diff --git a/src/views/mobile/transactions/EditPage.vue b/src/views/mobile/transactions/EditPage.vue index b5f1db7e..85b60cd7 100644 --- a/src/views/mobile/transactions/EditPage.vue +++ b/src/views/mobile/transactions/EditPage.vue @@ -367,6 +367,7 @@ import { getUtcOffsetByUtcOffsetMinutes, getActualUnixTimeForStore } from '@/lib/datetime.js'; +import { generateRandomUUID } from '@/lib/misc.js'; import { getTransactionPrimaryCategoryName, getTransactionSecondaryCategoryName, @@ -389,6 +390,7 @@ export default { mode: 'add', editTransactionId: null, transaction: newTransaction, + clientSessionId: '', loading: true, loadingError: null, geoLocationStatus: null, @@ -663,6 +665,10 @@ export default { self.transaction.type = parseInt(query.type); } + if (self.mode === 'add') { + self.clientSessionId = generateRandomUUID(); + } + Promise.all(promises).then(function (responses) { if (query.id && !responses[3]) { self.$toast('Unable to retrieve transaction'); @@ -723,7 +729,8 @@ export default { self.transactionsStore.saveTransaction({ transaction: self.transaction, defaultCurrency: self.defaultCurrency, - isEdit: self.mode === 'edit' + isEdit: self.mode === 'edit', + clientSessionId: self.clientSessionId }).then(() => { self.submitting = false; self.$hideLoading(); diff --git a/third-party-dependencies.json b/third-party-dependencies.json index 862115b5..d5ab2471 100644 --- a/third-party-dependencies.json +++ b/third-party-dependencies.json @@ -98,6 +98,12 @@ "url": "https://github.com/wk8/go-ordered-map", "licenseUrl": "https://github.com/wk8/go-ordered-map/blob/v2.1.8/LICENSE" }, + { + "name": "go-cache", + "copyright": "Copyright (c) 2012-2017 Patrick Mylund Nielsen and the go-cache contributors", + "url": "https://github.com/patrickmn/go-cache", + "licenseUrl": "https://github.com/patrickmn/go-cache/blob/v2.1.0/LICENSE" + }, { "name": "Testify", "copyright": "Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors.",