From b534091e8c28213af1ec31a407bcc3d194ff4808 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 24 May 2026 18:07:56 +0300 Subject: [PATCH 1/3] refactor: use timestamp+filename for bulk message request ID - Generate request ID as bulk-{base62_timestamp}-{truncated_filename} - Encode unix timestamp as base62 for minimal length (~6 chars) - Truncate filename to max 32 chars preserving extension - Remove fileType return value from ValidateStore - Simplify frontend cleanName() to just strip 'bulk-' prefix - Stay on bulk-messages page after upload instead of redirecting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/go.mod | 1 - api/go.sum | 2 - api/pkg/handlers/bulk_message_handler.go | 50 +++++++++++++------ .../bulk_message_handler_validator.go | 28 +++++------ web/pages/bulk-messages/index.vue | 13 ++--- 5 files changed, 50 insertions(+), 44 deletions(-) diff --git a/api/go.mod b/api/go.mod index 754baa14..1fe7dca1 100644 --- a/api/go.mod +++ b/api/go.mod @@ -33,7 +33,6 @@ require ( github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/jszwec/csvutil v1.10.0 github.com/lib/pq v1.12.3 - github.com/matoous/go-nanoid/v2 v2.1.0 github.com/nyaruka/phonenumbers v1.7.2 github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177 github.com/patrickmn/go-cache v2.1.0+incompatible diff --git a/api/go.sum b/api/go.sum index cb11e652..fa1163a2 100644 --- a/api/go.sum +++ b/api/go.sum @@ -237,8 +237,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= -github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= -github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go index 27d6286d..3637776c 100644 --- a/api/pkg/handlers/bulk_message_handler.go +++ b/api/pkg/handlers/bulk_message_handler.go @@ -1,10 +1,11 @@ package handlers import ( - "crypto/rand" "fmt" + "path/filepath" "sync" "sync/atomic" + "time" "github.com/NdoleStudio/httpsms/pkg/requests" "github.com/NdoleStudio/httpsms/pkg/services" @@ -12,7 +13,6 @@ import ( "github.com/NdoleStudio/httpsms/pkg/validators" "github.com/davecgh/go-spew/spew" "github.com/gofiber/fiber/v2" - gonanoid "github.com/matoous/go-nanoid/v2" "github.com/palantir/stacktrace" ) @@ -99,7 +99,7 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { return h.responseBadRequest(c, err) } - messages, fileType, userLocation, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file) + messages, userLocation, validationErrors := h.validator.ValidateStore(ctx, h.userIDFomContext(c), file) if len(validationErrors) != 0 { msg := fmt.Sprintf("validation errors [%s], while sending bulk sms from CSV file [%s] for [%s]", spew.Sdump(validationErrors), file.Filename, h.userIDFomContext(c)) ctxLogger.Warn(stacktrace.NewError(msg)) @@ -111,7 +111,7 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { return h.responsePaymentRequired(c, *msg) } - requestID := h.generateRequestID(fileType, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + requestID := h.generateRequestID(file.Filename) wg := sync.WaitGroup{} count := atomic.Int64{} @@ -145,21 +145,39 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { return h.responseAccepted(c, fmt.Sprintf("Added %d out of %d messages to the queue", count.Load(), len(messages))) } -func (h *BulkMessageHandler) generateRequestID(fileType string, alphabet string) string { - id, err := gonanoid.Generate(alphabet, 10) - if err != nil { - id = h.randomAlphaNum(10, alphabet) +func (h *BulkMessageHandler) generateRequestID(filename string) string { + timestamp := encodeBase62(time.Now().Unix()) + truncated := truncateFilename(filename, 32) + return fmt.Sprintf("bulk-%s-%s", timestamp, truncated) +} + +func encodeBase62(n int64) string { + const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + if n == 0 { + return "0" + } + result := make([]byte, 0, 8) + for n > 0 { + result = append(result, charset[n%62]) + n /= 62 + } + // reverse + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] } - return fmt.Sprintf("bulk-%s-%s", fileType, id) + return string(result) } -func (h *BulkMessageHandler) randomAlphaNum(length int, alphabet string) string { - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - return alphabet[:length] +func truncateFilename(filename string, maxLen int) string { + if len(filename) <= maxLen { + return filename } - for i := range b { - b[i] = alphabet[int(b[i])%len(alphabet)] + ext := filepath.Ext(filename) + name := filename[:len(filename)-len(ext)] + available := maxLen - len(ext) + if available <= 0 { + return filename[:maxLen] } - return string(b) + half := available / 2 + return name[:half] + name[len(name)-(available-half):] + ext } diff --git a/api/pkg/validators/bulk_message_handler_validator.go b/api/pkg/validators/bulk_message_handler_validator.go index f17f6adb..67537a93 100644 --- a/api/pkg/validators/bulk_message_handler_validator.go +++ b/api/pkg/validators/bulk_message_handler_validator.go @@ -52,7 +52,7 @@ func NewBulkMessageHandlerValidator( } // ValidateStore validates the requests.BillingUsageHistory request -func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, string, *time.Location, url.Values) { +func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID entities.UserID, header *multipart.FileHeader) ([]*requests.BulkMessage, *time.Location, url.Values) { ctx, span, ctxLogger := v.tracer.StartWithLogger(ctx, v.logger) defer span.End() @@ -61,22 +61,22 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID result := url.Values{} result.Add("document", "Cannot load your account. Please try again later or contact support.") ctxLogger.Error(v.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, fmt.Sprintf("cannot load user [%s]", userID)))) - return nil, "", nil, result + return nil, nil, result } - messages, fileType, result := v.parseFile(ctxLogger, user, header) + messages, result := v.parseFile(ctxLogger, user, header) if len(result) != 0 { - return messages, fileType, user.Location(), result + return messages, user.Location(), result } if len(messages) == 0 { result.Add("document", "The uploaded file doesn't contain any valid records. Make sure you are using the official httpSMS template.") - return messages, fileType, user.Location(), result + return messages, user.Location(), result } if len(messages) > 1000 { result.Add("document", "The uploaded file must contain less than 1000 records.") - return messages, fileType, user.Location(), result + return messages, user.Location(), result } for index, message := range messages { @@ -85,32 +85,30 @@ func (v *BulkMessageHandlerValidator) ValidateStore(ctx context.Context, userID result = v.validateMessages(ctx, messages, user.Location()) if len(result) != 0 { - return messages, fileType, user.Location(), result + return messages, user.Location(), result } result = v.validateOwners(ctx, userID, messages) if len(result) != 0 { - return messages, fileType, user.Location(), result + return messages, user.Location(), result } - return messages, fileType, user.Location(), result + return messages, user.Location(), result } -func (v *BulkMessageHandlerValidator) parseFile(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, string, url.Values) { +func (v *BulkMessageHandlerValidator) parseFile(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) { if header.Header.Get("Content-Type") == "text/csv" || strings.HasSuffix(header.Filename, ".csv") { - messages, result := v.parseCSV(ctxLogger, user, header) - return messages, "csv", result + return v.parseCSV(ctxLogger, user, header) } if header.Header.Get("Content-Type") == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || strings.HasSuffix(header.Filename, ".xlsx") { - messages, result := v.parseXlsx(ctxLogger, user, header) - return messages, "xls", result + return v.parseXlsx(ctxLogger, user, header) } ctxLogger.Error(stacktrace.NewError(fmt.Sprintf("cannot parse file [%s] for user [%s] with content type [%s]", header.Filename, user.ID, header.Header.Get("Content-Type")))) result := url.Values{} result.Add("document", fmt.Sprintf("The file [%s] is not a valid CSV or Excel file.", header.Filename)) - return nil, "", result + return nil, result } func (v *BulkMessageHandlerValidator) parseXlsx(ctxLogger telemetry.Logger, user *entities.User, header *multipart.FileHeader) ([]*requests.BulkMessage, url.Values) { diff --git a/web/pages/bulk-messages/index.vue b/web/pages/bulk-messages/index.vue index 74632210..0a946b91 100644 --- a/web/pages/bulk-messages/index.vue +++ b/web/pages/bulk-messages/index.vue @@ -221,12 +221,6 @@ export default Vue.extend({ }, methods: { cleanName(requestId: string): string { - if (requestId.startsWith('bulk-csv-')) { - return requestId.replace(/^bulk-csv-/, '') + '.csv' - } - if (requestId.startsWith('bulk-xls-')) { - return requestId.replace(/^bulk-xls-/, '') + '.xlsx' - } return requestId.replace(/^bulk-/, '') }, fetchBulkOrders() { @@ -251,10 +245,9 @@ export default Vue.extend({ this.$store .dispatch('sendBulkMessages', this.formFile) .then(() => { - setTimeout(() => { - this.loading = false - this.$router.push({ name: 'threads' }) - }, 2000) + this.loading = false + this.formFile = null + this.fetchBulkOrders() }) .catch((error: AxiosError) => { this.errorTitle = capitalize( From 7b29eb589206107534edd34d951cf34f56ba72b8 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 24 May 2026 18:16:11 +0300 Subject: [PATCH 2/3] fix: address PR review comments - Add 4-char random base62 suffix to prevent same-second collisions - Sanitize filename by stripping non-alphanumeric chars (except . and -) - Restore backward-compat in cleanName for old bulk-csv-/bulk-xls- entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/handlers/bulk_message_handler.go | 25 ++++++++++++++++++++++-- web/pages/bulk-messages/index.vue | 6 ++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go index 3637776c..534d5a76 100644 --- a/api/pkg/handlers/bulk_message_handler.go +++ b/api/pkg/handlers/bulk_message_handler.go @@ -1,8 +1,10 @@ package handlers import ( + "crypto/rand" "fmt" "path/filepath" + "regexp" "sync" "sync/atomic" "time" @@ -147,8 +149,27 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { func (h *BulkMessageHandler) generateRequestID(filename string) string { timestamp := encodeBase62(time.Now().Unix()) - truncated := truncateFilename(filename, 32) - return fmt.Sprintf("bulk-%s-%s", timestamp, truncated) + suffix := randomBase62(4) + truncated := truncateFilename(sanitizeFilename(filename), 32) + return fmt.Sprintf("bulk-%s%s-%s", timestamp, suffix, truncated) +} + +var unsafeCharsRegex = regexp.MustCompile(`[^a-zA-Z0-9.\-]`) + +func sanitizeFilename(filename string) string { + return unsafeCharsRegex.ReplaceAllString(filename, "") +} + +func randomBase62(length int) string { + const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return charset[:length] + } + for i := range b { + b[i] = charset[int(b[i])%len(charset)] + } + return string(b) } func encodeBase62(n int64) string { diff --git a/web/pages/bulk-messages/index.vue b/web/pages/bulk-messages/index.vue index 0a946b91..22382278 100644 --- a/web/pages/bulk-messages/index.vue +++ b/web/pages/bulk-messages/index.vue @@ -221,6 +221,12 @@ export default Vue.extend({ }, methods: { cleanName(requestId: string): string { + if (requestId.startsWith('bulk-csv-')) { + return requestId.replace(/^bulk-csv-/, '') + '.csv' + } + if (requestId.startsWith('bulk-xls-')) { + return requestId.replace(/^bulk-xls-/, '') + '.xlsx' + } return requestId.replace(/^bulk-/, '') }, fetchBulkOrders() { From ada4b90a2a48f4e3166f6d0978afe3e8efb1bed0 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Sun, 24 May 2026 18:25:00 +0300 Subject: [PATCH 3/3] fix: allow space character in sanitizeFilename Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/handlers/bulk_message_handler.go | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go index 534d5a76..49b4f591 100644 --- a/api/pkg/handlers/bulk_message_handler.go +++ b/api/pkg/handlers/bulk_message_handler.go @@ -1,7 +1,6 @@ package handlers import ( - "crypto/rand" "fmt" "path/filepath" "regexp" @@ -148,28 +147,11 @@ func (h *BulkMessageHandler) Store(c *fiber.Ctx) error { } func (h *BulkMessageHandler) generateRequestID(filename string) string { - timestamp := encodeBase62(time.Now().Unix()) - suffix := randomBase62(4) - truncated := truncateFilename(sanitizeFilename(filename), 32) - return fmt.Sprintf("bulk-%s%s-%s", timestamp, suffix, truncated) + return fmt.Sprintf("bulk-%s-%s", encodeBase62(time.Now().Unix()), truncateFilename(sanitizeFilename(filename), 32)) } -var unsafeCharsRegex = regexp.MustCompile(`[^a-zA-Z0-9.\-]`) - func sanitizeFilename(filename string) string { - return unsafeCharsRegex.ReplaceAllString(filename, "") -} - -func randomBase62(length int) string { - const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - b := make([]byte, length) - if _, err := rand.Read(b); err != nil { - return charset[:length] - } - for i := range b { - b[i] = charset[int(b[i])%len(charset)] - } - return string(b) + return regexp.MustCompile(`[^a-zA-Z0-9.\-_: ]`).ReplaceAllString(filename, "") } func encodeBase62(n int64) string {