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 @@
+
+
+
+
+
+
+
+ {{ $t('Export Data') }}
+
+
+
+
+
+
+