From 7d3e05c5480d40d576802f4d6cfe3b56139326dd Mon Sep 17 00:00:00 2001 From: MaysWind Date: Sat, 2 Jan 2021 02:04:38 +0800 Subject: [PATCH] support data export --- cmd/webserver.go | 25 +- conf/lab.ini | 4 + pkg/api/accounts.go | 7 +- pkg/api/data_managements.go | 269 ++++++++++++++++++++++ pkg/core/handler.go | 3 + pkg/errs/data_managements.go | 10 + pkg/errs/error.go | 17 +- pkg/middlewares/authorization.go | 27 ++- pkg/middlewares/recovery.go | 2 +- pkg/middlewares/server_settings_cookie.go | 1 + pkg/services/accounts.go | 11 + pkg/services/transaction_categories.go | 11 + pkg/services/transaction_tags.go | 58 +++-- pkg/settings/setting.go | 15 ++ pkg/utils/api.go | 38 ++- pkg/utils/datetimes.go | 12 + pkg/utils/strings.go | 3 +- src/lib/services.js | 8 +- src/lib/settings.js | 1 + src/locales/en.js | 2 + src/locales/zh_Hans.js | 2 + src/router/mobile.js | 6 + src/views/mobile/Settings.vue | 4 + src/views/mobile/users/DataManagement.vue | 21 ++ 24 files changed, 515 insertions(+), 42 deletions(-) create mode 100644 pkg/api/data_managements.go create mode 100644 pkg/errs/data_managements.go create mode 100644 src/views/mobile/users/DataManagement.vue diff --git a/cmd/webserver.go b/cmd/webserver.go index a315417c..d2cb5552 100644 --- a/cmd/webserver.go +++ b/cmd/webserver.go @@ -155,6 +155,14 @@ func startWebServer(c *cli.Context) error { apiRoute.POST("/register.json", bindApi(api.Users.UserRegistrationNotAllowed)) } + if config.EnableDataExport { + dataRoute := apiRoute.Group("/data") + dataRoute.Use(bindMiddleware(middlewares.JWTAuthorizationByQueryString)) + { + dataRoute.GET("/export.csv", bindCsv(api.DataManagements.ExportDataHandler)) + } + } + apiRoute.GET("/logout.json", bindApi(api.Tokens.TokenRevokeCurrentHandler)) apiV1Route := apiRoute.Group("/v1") @@ -255,9 +263,22 @@ func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc { result, err := fn(c) if err != nil { - utils.PrintErrorResult(c, err) + utils.PrintJsonErrorResult(c, err) } else { - utils.PrintSuccessResult(c, result) + utils.PrintJsonSuccessResult(c, result) + } + } +} + +func bindCsv(fn core.DataHandlerFunc) gin.HandlerFunc { + return func(ginCtx *gin.Context) { + c := core.WrapContext(ginCtx) + result, fileName, err := fn(c) + + if err != nil { + utils.PrintDataErrorResult(c, "text/text", err) + } else { + utils.PrintDataSuccessResult(c, "text/csv", fileName, result) } } } diff --git a/conf/lab.ini b/conf/lab.ini index 874b82f8..7960fe86 100644 --- a/conf/lab.ini +++ b/conf/lab.ini @@ -105,3 +105,7 @@ request_id_header = true [user] # Set to true to allow users to register account by themselves enable_register = true + +[data] +# Set to true to allow users to export their data +enable_export = true diff --git a/pkg/api/accounts.go b/pkg/api/accounts.go index be5c5a75..c1c00bb0 100644 --- a/pkg/api/accounts.go +++ b/pkg/api/accounts.go @@ -226,12 +226,7 @@ func (a *AccountsApi) AccountModifyHandler(c *core.Context) (interface{}, *errs. return nil, errs.ErrOperationFailed } - accountMap := make(map[int64]*models.Account) - - for i := 0; i < len(accountAndSubAccounts); i++ { - acccount := accountAndSubAccounts[i] - accountMap[acccount.AccountId] = acccount - } + accountMap := a.accounts.GetAccountMapByList(accountAndSubAccounts) if _, exists := accountMap[accountModifyReq.Id]; !exists { return nil, errs.ErrAccountNotFound diff --git a/pkg/api/data_managements.go b/pkg/api/data_managements.go new file mode 100644 index 00000000..7971534d --- /dev/null +++ b/pkg/api/data_managements.go @@ -0,0 +1,269 @@ +package api + +import ( + "fmt" + "strings" + "time" + + "github.com/mayswind/lab/pkg/core" + "github.com/mayswind/lab/pkg/errs" + "github.com/mayswind/lab/pkg/log" + "github.com/mayswind/lab/pkg/models" + "github.com/mayswind/lab/pkg/services" + "github.com/mayswind/lab/pkg/settings" + "github.com/mayswind/lab/pkg/utils" +) + +const pageCountForDataExport = 1000 +const csvHeaderLine = "Time,Type,Category,Sub Category,Account,Amount,Account2,Account2 Amount,Tags,Comment\n" +const csvDataLineFormat = "%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n" + +// DataManagementsApi represents data management api +type DataManagementsApi struct { + tokens *services.TokenService + users *services.UserService + accounts *services.AccountService + transactions *services.TransactionService + categories *services.TransactionCategoryService + tags *services.TransactionTagService +} + +// Initialize a data management api singleton instance +var ( + DataManagements = &DataManagementsApi{ + tokens: services.Tokens, + users: services.Users, + accounts: services.Accounts, + transactions: services.Transactions, + categories: services.TransactionCategories, + tags: services.TransactionTags, + } +) + +// ExportDataHandler returns exported data in csv format +func (a *DataManagementsApi) ExportDataHandler(c *core.Context) ([]byte, string, *errs.Error) { + if !settings.Container.Current.EnableDataExport { + return nil, "", errs.ErrDataExportNotAllowed + } + + uid := c.GetCurrentUid() + + accounts, err := a.accounts.GetAllAccountsByUid(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get all accounts for user \"uid:%d\", because %s", uid, err.Error()) + return nil, "", errs.ErrOperationFailed + } + + categories, err := a.categories.GetAllCategoriesByUid(uid, 0, -1) + + if err != nil { + log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get categories for user \"uid:%d\", because %s", uid, err.Error()) + return nil, "", errs.ErrOperationFailed + } + + tags, err := a.tags.GetAllTagsByUid(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tags for user \"uid:%d\", because %s", uid, err.Error()) + return nil, "", errs.ErrOperationFailed + } + + tagIndexs, err := a.tags.GetAllTagIdsOfAllTransactions(uid) + + if err != nil { + log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get tag index for user \"uid:%d\", because %s", uid, err.Error()) + return nil, "", errs.ErrOperationFailed + } + + accountMap := a.accounts.GetAccountMapByList(accounts) + categoryMap := a.categories.GetCategoryMapByList(categories) + tagMap := a.tags.GetTagMapByList(tags) + + maxTime := utils.GetMaxTransactionTimeFromUnixTime(time.Now().Unix()) + var allTransactions []*models.Transaction + + for maxTime > 0 { + transactions, err := a.transactions.GetTransactionsByMaxTime(uid, maxTime, nil, 0, 0, pageCountForDataExport) + + if err != nil { + log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get transactions earlier than \"%d\" for user \"uid:%d\", because %s", maxTime, uid, err.Error()) + return nil, "", errs.ErrOperationFailed + } + + allTransactions = append(allTransactions, transactions...) + + if len(transactions) < pageCountForDataExport { + maxTime = 0 + break + } + + maxTime = transactions[len(transactions)-1].TransactionTime - 1 + } + + result, err := a.getCSVFormatData(c, allTransactions, accountMap, categoryMap, tagMap, tagIndexs) + + if err != nil { + log.ErrorfWithRequestId(c, "[data_managements.ExportDataHandler] failed to get csv format exported data for \"uid:%d\", because %s", uid, err.Error()) + return nil, "", errs.Or(err, errs.ErrOperationFailed) + } + + fileName := a.getFileName(c) + + return []byte(result), fileName, nil +} + +func (a *DataManagementsApi) getCSVFormatData(c *core.Context, transactions []*models.Transaction, accountMap map[int64]*models.Account, categoryMap map[int64]*models.TransactionCategory, tagMap map[int64]*models.TransactionTag, allTagIndexs map[int64][]int64) (string, error) { + + var ret strings.Builder + + ret.Grow(len(transactions) * 100) + ret.WriteString(csvHeaderLine) + + for i := 0; i < len(transactions); i++ { + transaction := transactions[i] + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_IN { + continue + } + + transactionTime := utils.FormatToLongDateTimeWithoutSecond(utils.ParseFromUnixTime(utils.GetUnixTimeFromTransactionTime(transaction.TransactionTime))) + transactionType := a.getTransactionTypeName(c, transaction.Type) + category := a.getTransactionCategoryName(c, transaction.CategoryId, categoryMap) + subCategory := a.getTransactionSubCategoryName(c, transaction.CategoryId, categoryMap) + account := a.getAccountName(c, transaction.AccountId, accountMap) + amount := a.getDisplayAmount(c, transaction.Amount) + account2 := "" + account2Amount := "" + + if transaction.Type == models.TRANSACTION_DB_TYPE_TRANSFER_OUT { + account2 = a.getAccountName(c, transaction.RelatedAccountId, accountMap) + account2Amount = a.getDisplayAmount(c, transaction.RelatedAccountAmount) + } + + tags := a.getTags(c, transaction.TransactionId, allTagIndexs, tagMap) + comment := a.getComment(c, transaction.Comment) + + ret.WriteString(fmt.Sprintf(csvDataLineFormat, transactionTime, transactionType, category, subCategory, account, amount, account2, account2Amount, tags, comment)) + } + + return ret.String(), nil +} + +func (a *DataManagementsApi) getTransactionTypeName(c *core.Context, transactionDbType models.TransactionDbType) string { + if transactionDbType == models.TRANSACTION_DB_TYPE_MODIFY_BALANCE { + return "Balance Modification" + } else if transactionDbType == models.TRANSACTION_DB_TYPE_INCOME { + return "Income" + } else if transactionDbType == models.TRANSACTION_DB_TYPE_EXPENSE { + return "Expense" + } else if transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_OUT || transactionDbType == models.TRANSACTION_DB_TYPE_TRANSFER_IN { + return "Transfer" + } else { + return "" + } +} + +func (a *DataManagementsApi) getTransactionCategoryName(c *core.Context, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string { + category, exists := categoryMap[categoryId] + + if !exists { + return "" + } + + if category.ParentCategoryId == 0 { + return category.Name + } + + parentCategory, exists := categoryMap[category.ParentCategoryId] + + if !exists { + return "" + } + + return parentCategory.Name +} + +func (a *DataManagementsApi) getTransactionSubCategoryName(c *core.Context, categoryId int64, categoryMap map[int64]*models.TransactionCategory) string { + category, exists := categoryMap[categoryId] + + if exists { + return category.Name + } else { + return "" + } +} + +func (a *DataManagementsApi) getAccountName(c *core.Context, accountId int64, accountMap map[int64]*models.Account) string { + account, exists := accountMap[accountId] + + if exists { + return account.Name + } else { + return "" + } +} + +func (a *DataManagementsApi) getDisplayAmount(c *core.Context, amount int64) string { + displayAmount := utils.Int64ToString(amount) + integer := utils.SubString(displayAmount, 0, len(displayAmount)-2) + decimals := utils.SubString(displayAmount, -2, 2) + + if integer == "" { + integer = "0" + } else if integer == "-" { + integer = "-0" + } + + if len(decimals) == 0 { + decimals = "00" + } else if len(decimals) == 1 { + decimals = "0" + decimals + } + + return integer + "." + decimals +} + +func (a *DataManagementsApi) getTags(c *core.Context, transactionId int64, allTagIndexs map[int64][]int64, tagMap map[int64]*models.TransactionTag) string { + tagIndexs, exists := allTagIndexs[transactionId] + + if !exists { + return "" + } + + var ret strings.Builder + + for i := 0; i < len(tagIndexs); i++ { + if i > 0 { + ret.WriteString(";") + } + + tagIndex := tagIndexs[i] + tag, exists := tagMap[tagIndex] + + if !exists { + continue + } + + ret.WriteString(tag.Name) + } + + return ret.String() +} + +func (a *DataManagementsApi) getComment(c *core.Context, comment string) string { + comment = strings.Replace(comment, ",", " ", -1) + comment = strings.Replace(comment, "\r\n", " ", -1) + comment = strings.Replace(comment, "\n", " ", -1) + + return comment +} + +func (a *DataManagementsApi) getFileName(c *core.Context) string { + currentTime := utils.FormatToLongDateTimeWithoutSecond(time.Now()) + currentTime = strings.Replace(currentTime, "-", "_", -1) + currentTime = strings.Replace(currentTime, " ", "_", -1) + currentTime = strings.Replace(currentTime, ":", "_", -1) + + return fmt.Sprintf("%s.csv", currentTime) +} diff --git a/pkg/core/handler.go b/pkg/core/handler.go index 1a5c6161..a82ecd2d 100644 --- a/pkg/core/handler.go +++ b/pkg/core/handler.go @@ -7,3 +7,6 @@ type MiddlewareHandlerFunc func(*Context) // ApiHandlerFunc represents the api handler function type ApiHandlerFunc func(*Context) (interface{}, *errs.Error) + +// DataHandlerFunc represents the handler function that returns byte array +type DataHandlerFunc func(*Context) ([]byte, string, *errs.Error) diff --git a/pkg/errs/data_managements.go b/pkg/errs/data_managements.go new file mode 100644 index 00000000..4687e137 --- /dev/null +++ b/pkg/errs/data_managements.go @@ -0,0 +1,10 @@ +package errs + +import ( + "net/http" +) + +// Error codes related to data management +var ( + ErrDataExportNotAllowed = NewNormalError(NormalSubcategoryDataManagement, 1, http.StatusBadRequest, "data export not allowed") +) diff --git a/pkg/errs/error.go b/pkg/errs/error.go index e53ec63d..1d78be07 100644 --- a/pkg/errs/error.go +++ b/pkg/errs/error.go @@ -18,14 +18,15 @@ const ( // Sub categories of normal error const ( - NormalSubcategoryGlobal = 0 - NormalSubcategoryUser = 1 - NormalSubcategoryToken = 2 - NormalSubcategoryTwofactor = 3 - NormalSubcategoryAccount = 4 - NormalSubcategoryTransaction = 5 - NormalSubcategoryCategory = 6 - NormalSubcategoryTag = 7 + NormalSubcategoryGlobal = 0 + NormalSubcategoryUser = 1 + NormalSubcategoryToken = 2 + NormalSubcategoryTwofactor = 3 + NormalSubcategoryAccount = 4 + NormalSubcategoryTransaction = 5 + NormalSubcategoryCategory = 6 + NormalSubcategoryTag = 7 + NormalSubcategoryDataManagement = 8 ) // Error represents the specific error returned to user diff --git a/pkg/middlewares/authorization.go b/pkg/middlewares/authorization.go index 407c16ff..bc62cdf9 100644 --- a/pkg/middlewares/authorization.go +++ b/pkg/middlewares/authorization.go @@ -10,24 +10,26 @@ import ( "github.com/mayswind/lab/pkg/utils" ) +const tokenQueryStringParam = "token" + // JWTAuthorization verifies whether current request is valid by jwt token func JWTAuthorization(c *core.Context) { claims, err := getTokenClaims(c) if err != nil { - utils.PrintErrorResult(c, err) + utils.PrintJsonErrorResult(c, err) return } if claims.Type == core.USER_TOKEN_TYPE_REQUIRE_2FA { log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token requires 2fa", claims.Id) - utils.PrintErrorResult(c, errs.ErrCurrentTokenRequire2FA) + utils.PrintJsonErrorResult(c, errs.ErrCurrentTokenRequire2FA) return } if claims.Type != core.USER_TOKEN_TYPE_NORMAL { log.WarnfWithRequestId(c, "[authorization.JWTAuthorization] user \"uid:%s\" token type is invalid", claims.Id) - utils.PrintErrorResult(c, errs.ErrCurrentInvalidTokenType) + utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidTokenType) return } @@ -35,18 +37,33 @@ func JWTAuthorization(c *core.Context) { c.Next() } +// JWTAuthorizationByQueryString verifies whether current request is valid by jwt token +func JWTAuthorizationByQueryString(c *core.Context) { + token, exists := c.GetQuery(tokenQueryStringParam) + + if !exists { + log.ErrorfWithRequestId(c, "[authorization.JWTAuthorizationByQueryString] no token provided") + utils.PrintJsonErrorResult(c, errs.ErrUnauthorizedAccess) + return + } + + c.Request.Header.Set("Authorization", token) + + JWTAuthorization(c) +} + // JWTTwoFactorAuthorization verifies whether current request is valid by 2fa passcode func JWTTwoFactorAuthorization(c *core.Context) { claims, err := getTokenClaims(c) if err != nil { - utils.PrintErrorResult(c, err) + utils.PrintJsonErrorResult(c, err) return } if claims.Type != core.USER_TOKEN_TYPE_REQUIRE_2FA { log.WarnfWithRequestId(c, "[authorization.JWTTwoFactorAuthorization] user \"uid:%s\" token is not need two factor authorization", claims.Id) - utils.PrintErrorResult(c, errs.ErrCurrentTokenNotRequire2FA) + utils.PrintJsonErrorResult(c, errs.ErrCurrentTokenNotRequire2FA) return } diff --git a/pkg/middlewares/recovery.go b/pkg/middlewares/recovery.go index 38922fd0..383046af 100644 --- a/pkg/middlewares/recovery.go +++ b/pkg/middlewares/recovery.go @@ -26,7 +26,7 @@ func Recovery(c *core.Context) { stack := stack(3) log.ErrorfWithRequestIdAndExtra(c, string(stack), "System Error! because %s", err) - utils.PrintErrorResult(c, errs.ErrSystemError) + utils.PrintJsonErrorResult(c, errs.ErrSystemError) } }() diff --git a/pkg/middlewares/server_settings_cookie.go b/pkg/middlewares/server_settings_cookie.go index 43dc1534..4f4846dc 100644 --- a/pkg/middlewares/server_settings_cookie.go +++ b/pkg/middlewares/server_settings_cookie.go @@ -15,6 +15,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc { return func(c *core.Context) { settingsArr := []string{ buildBooleanSetting("r", config.EnableUserRegister), + buildBooleanSetting("e", config.EnableDataExport), } bundledSettings := strings.Join(settingsArr, "_") diff --git a/pkg/services/accounts.go b/pkg/services/accounts.go index 1b8f7981..a77f83ff 100644 --- a/pkg/services/accounts.go +++ b/pkg/services/accounts.go @@ -321,3 +321,14 @@ func (s *AccountService) DeleteAccount(uid int64, accountId int64) error { return err }) } + +// GetAccountMapByList returns an account map by a list +func (s *AccountService) GetAccountMapByList(accounts []*models.Account) map[int64]*models.Account { + accountMap := make(map[int64]*models.Account) + + for i := 0; i < len(accounts); i++ { + account := accounts[i] + accountMap[account.AccountId] = account + } + return accountMap +} diff --git a/pkg/services/transaction_categories.go b/pkg/services/transaction_categories.go index f450594e..847bc58d 100644 --- a/pkg/services/transaction_categories.go +++ b/pkg/services/transaction_categories.go @@ -315,3 +315,14 @@ func (s *TransactionCategoryService) DeleteCategory(uid int64, categoryId int64) return err }) } + +// GetCategoryMapByList returns a transaction category map by a list +func (s *TransactionCategoryService) GetCategoryMapByList(categories []*models.TransactionCategory) map[int64]*models.TransactionCategory { + categoryMap := make(map[int64]*models.TransactionCategory) + + for i := 0; i < len(categories); i++ { + category := categories[i] + categoryMap[category.CategoryId] = category + } + return categoryMap +} diff --git a/pkg/services/transaction_tags.go b/pkg/services/transaction_tags.go index da66f684..e06819a0 100644 --- a/pkg/services/transaction_tags.go +++ b/pkg/services/transaction_tags.go @@ -83,6 +83,20 @@ func (s *TransactionTagService) GetMaxDisplayOrder(uid int64) (int, error) { } } +// GetAllTagIdsOfAllTransactions returns all transaction tag ids +func (s *TransactionTagService) GetAllTagIdsOfAllTransactions(uid int64) (map[int64][]int64, error) { + if uid <= 0 { + return nil, errs.ErrUserIdInvalid + } + + var tagIndexs []*models.TransactionTagIndex + err := s.UserDataDB(uid).Where("uid=?", uid).Find(&tagIndexs) + + allTransactionTagIds := s.getGroupedTransactionTagIds(tagIndexs) + + return allTransactionTagIds, err +} + // GetAllTagIdsOfTransactions returns transaction tag ids for given transactions func (s *TransactionTagService) GetAllTagIdsOfTransactions(uid int64, transactionIds []int64) (map[int64][]int64, error) { if uid <= 0 { @@ -92,20 +106,7 @@ func (s *TransactionTagService) GetAllTagIdsOfTransactions(uid int64, transactio var tagIndexs []*models.TransactionTagIndex err := s.UserDataDB(uid).Where("uid=?", uid).In("transaction_id", transactionIds).Find(&tagIndexs) - allTransactionTagIds := make(map[int64][]int64) - - for i := 0; i < len(tagIndexs); i++ { - tagIndex := tagIndexs[i] - - var transactionTagIds []int64 - - if _, exists := allTransactionTagIds[tagIndex.TransactionId]; exists { - transactionTagIds = allTransactionTagIds[tagIndex.TransactionId] - } - - transactionTagIds = append(transactionTagIds, tagIndex.TagId) - allTransactionTagIds[tagIndex.TransactionId] = transactionTagIds - } + allTransactionTagIds := s.getGroupedTransactionTagIds(tagIndexs) return allTransactionTagIds, err } @@ -251,3 +252,32 @@ func (s *TransactionTagService) ExistsTagName(uid int64, name string) (bool, err return s.UserDB().Cols("name").Where("uid=? AND name=?", uid, name).Exist(&models.TransactionTag{}) } + +// GetTagMapByList returns a transaction tag map by a list +func (s *TransactionTagService) GetTagMapByList(tags []*models.TransactionTag) map[int64]*models.TransactionTag { + tagMap := make(map[int64]*models.TransactionTag) + + for i := 0; i < len(tags); i++ { + tag := tags[i] + tagMap[tag.TagId] = tag + } + return tagMap +} + +func (s *TransactionTagService) getGroupedTransactionTagIds(tagIndexs []*models.TransactionTagIndex) map[int64][]int64 { + allTransactionTagIds := make(map[int64][]int64) + + for i := 0; i < len(tagIndexs); i++ { + tagIndex := tagIndexs[i] + + var transactionTagIds []int64 + + if _, exists := allTransactionTagIds[tagIndex.TransactionId]; exists { + transactionTagIds = allTransactionTagIds[tagIndex.TransactionId] + } + + transactionTagIds = append(transactionTagIds, tagIndex.TagId) + allTransactionTagIds[tagIndex.TransactionId] = transactionTagIds + } + return allTransactionTagIds +} diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 4328c19d..a06ca5e6 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -153,6 +153,9 @@ type Config struct { // User EnableUserRegister bool + + // Data + EnableDataExport bool } // LoadConfiguration loads setting config from given config file path @@ -214,6 +217,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) { return nil, err } + err = loadDataConfiguration(config, cfgFile, "data") + + if err != nil { + return nil, err + } + return config, nil } @@ -377,6 +386,12 @@ func loadUserConfiguration(config *Config, configFile *ini.File, sectionName str return nil } +func loadDataConfiguration(config *Config, configFile *ini.File, sectionName string) error { + config.EnableDataExport = getConfigItemBoolValue(configFile, sectionName, "enable_export", false) + + return nil +} + func getWorkingPath() (string, error) { workingPath := os.Getenv(labWorkDirEnvName) diff --git a/pkg/utils/api.go b/pkg/utils/api.go index b856eeb8..86c53be5 100644 --- a/pkg/utils/api.go +++ b/pkg/utils/api.go @@ -11,16 +11,25 @@ import ( "github.com/mayswind/lab/pkg/errs" ) -// PrintSuccessResult writes success response to current http context -func PrintSuccessResult(c *core.Context, result interface{}) { +// PrintJsonSuccessResult writes success response in json format to current http context +func PrintJsonSuccessResult(c *core.Context, result interface{}) { c.JSON(http.StatusOK, gin.H{ "success": true, "result": result, }) } -// PrintErrorResult writes error response to current http context -func PrintErrorResult(c *core.Context, err *errs.Error) { +// PrintDataSuccessResult writes success response in custom content type to current http context +func PrintDataSuccessResult(c *core.Context, contentType string, fileName string, result []byte) { + if fileName != "" { + c.Header("Content-Disposition", "attachment;filename=" + fileName) + } + + c.Data(http.StatusOK, contentType, result) +} + +// PrintJsonErrorResult writes error response in json format to current http context +func PrintJsonErrorResult(c *core.Context, err *errs.Error) { c.SetResponseError(err) errorMessage := err.Error() @@ -44,6 +53,27 @@ func PrintErrorResult(c *core.Context, err *errs.Error) { }) } +// PrintDataErrorResult writes error response in custom content type to current http context +func PrintDataErrorResult(c *core.Context, contentType string, err *errs.Error) { + c.SetResponseError(err) + + errorMessage := err.Error() + + if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 { + validationErrors, ok := err.BaseError[0].(validator.ValidationErrors) + + if ok { + for _, err := range validationErrors { + errorMessage = getValidationErrorText(err) + break + } + } + } + + c.Data(err.HttpStatusCode, contentType, []byte(errorMessage)) + c.Abort() +} + func getValidationErrorText(err validator.FieldError) string { fieldName := GetFirstLowerCharString(err.Field()) diff --git a/pkg/utils/datetimes.go b/pkg/utils/datetimes.go index b51d6a90..e36e84e0 100644 --- a/pkg/utils/datetimes.go +++ b/pkg/utils/datetimes.go @@ -3,7 +3,9 @@ package utils import "time" const ( + unixTimeFormat = "1136239445" longDateTimeFormat = "2006-01-02 15:04:05" + longDateTimeWithoutSecondFormat = "2006-01-02 15:04" ) // FormatToLongDateTime returns a textual representation of the time value formatted by long date time format @@ -11,6 +13,16 @@ func FormatToLongDateTime(t time.Time) string { return t.Format(longDateTimeFormat) } +// FormatToLongDateTimeWithoutSecond returns a textual representation of the time value formatted by long date time format (no second) +func FormatToLongDateTimeWithoutSecond(t time.Time) string { + return t.Format(longDateTimeWithoutSecondFormat) +} + +// ParseFromUnixTime parses a unix time and returns a golang time struct +func ParseFromUnixTime(unixTime int64) time.Time { + return time.Unix(unixTime, 0) +} + // ParseFromLongDateTime parses a formatted string in long date time format func ParseFromLongDateTime(t string) (time.Time, error) { return time.Parse(longDateTimeFormat, t) diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go index 8b417277..8d70f238 100644 --- a/pkg/utils/strings.go +++ b/pkg/utils/strings.go @@ -29,8 +29,9 @@ func SubString(str string, start int, length int) string { end := 0 if start < 0 { - start = realLength - 1 + start + start = realLength + start } + end = start + length if start > end { diff --git a/src/lib/services.js b/src/lib/services.js index 695e0fef..d02d81e5 100644 --- a/src/lib/services.js +++ b/src/lib/services.js @@ -3,10 +3,12 @@ import moment from 'moment'; import userState from "./userstate.js"; import exchangeRates from "./exchangeRates.js"; +const baseUrlPath = '/api'; + let needBlockRequest = false; let blockedRequests = []; -axios.defaults.baseURL = '/api'; +axios.defaults.baseURL = baseUrlPath; axios.interceptors.request.use(config => { const token = userState.getToken(); @@ -121,6 +123,10 @@ export default { blockedRequests.length = 0; }); }, + getDataExportUrl: () => { + const token = userState.getToken(); + return `${baseUrlPath}/data/export.csv?token=${token}`; + }, getTokens: () => { return axios.get('v1/tokens/list.json'); }, diff --git a/src/lib/settings.js b/src/lib/settings.js index ecba35f9..7e3802a4 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -95,5 +95,6 @@ export default { isEnableAutoDarkMode: () => getOption('autoDarkMode'), setEnableAutoDarkMode: value => setOption('autoDarkMode', value), isUserRegistrationEnabled: () => getServerSetting('r') === '1', + isDataExportingEnabled: () => getServerSetting('e') === '1', clearSettings: clearSettings }; diff --git a/src/locales/en.js b/src/locales/en.js index 4ba15211..fcce3cbe 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -574,6 +574,8 @@ export default { 'Nothing has been modified': 'Nothing has been modified', 'Your profile has been successfully updated': 'Your profile has been successfully updated', 'Unable to update user profile': 'Unable to update user profile', + 'Data Management': 'Data Management', + 'Export Data': 'Export Data', 'Device & Sessions': 'Device & Sessions', 'Logout All': 'Logout All', 'Unable to get session list': 'Unable to get session list', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index d57b1159..892843ad 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -574,6 +574,8 @@ export default { 'Nothing has been modified': '没有修改的项目', 'Your profile has been successfully updated': '您的用户信息更新成功', 'Unable to update user profile': '无法更新用户信息', + 'Data Management': '数据管理', + 'Export Data': '导出数据', 'Device & Sessions': '设备和会话', 'Logout All': '注销全部', 'Unable to get session list': '无法获取会话列表', diff --git a/src/router/mobile.js b/src/router/mobile.js index 57119cff..36516fe8 100644 --- a/src/router/mobile.js +++ b/src/router/mobile.js @@ -19,6 +19,7 @@ import ExchangeRatesPage from "../views/mobile/ExchangeRates.vue"; import AboutPage from "../views/mobile/About.vue"; import UserProfilePage from "../views/mobile/users/UserProfile.vue"; +import DataManagementPage from "../views/mobile/users/DataManagement.vue"; import TwoFactorAuthPage from "../views/mobile/users/TwoFactorAuth.vue"; import SessionListPage from "../views/mobile/users/SessionList.vue"; @@ -199,6 +200,11 @@ const routes = [ component: UserProfilePage, beforeEnter: checkLogin }, + { + path: '/user/data/management', + component: DataManagementPage, + beforeEnter: checkLogin + }, { path: '/user/2fa', component: TwoFactorAuthPage, diff --git a/src/views/mobile/Settings.vue b/src/views/mobile/Settings.vue index 5ef15b0c..2c9c61d9 100644 --- a/src/views/mobile/Settings.vue +++ b/src/views/mobile/Settings.vue @@ -9,6 +9,7 @@ + {{ $t('Log Out') }} @@ -107,6 +108,9 @@ export default { this.exchangeRatesLastUpdateDate = this.getExchangeRatesLastUpdateDate(); } }, + isDataExportingEnabled() { + return this.$settings.isDataExportingEnabled(); + }, isAutoUpdateExchangeRatesData: { get: function () { return this.$settings.isAutoUpdateExchangeRatesData(); diff --git a/src/views/mobile/users/DataManagement.vue b/src/views/mobile/users/DataManagement.vue new file mode 100644 index 00000000..b16f962d --- /dev/null +++ b/src/views/mobile/users/DataManagement.vue @@ -0,0 +1,21 @@ + + +