fix: panic recovery, rate limiting, transaction error handling, pagination

Hardening pass over the API surface. No route's auth requirements change.

Resilience:
- Add recover middleware. There was none, so an unhandled panic in any
  handler propagated out of the process instead of becoming a 500.
- Add a centralized ErrorHandler so errors and recovered panics return the
  same {success,message} envelope as the utils helpers, not Fiber's default
  plain-text body. 5xx responses are logged with method and path.

Rate limiting:
- Global 300/min per IP as an abuse backstop, exempting health/readiness
  probes and websocket upgrades.
- 10/min shared across every credential endpoint (customer/miler/admin/hub
  login, verify-pin, reset-pin, email OTP). PINs are 4 digits, so the whole
  keyspace was previously walkable in seconds. One shared limiter instance
  means rotating between endpoints doesn't reset the budget.
- Add TRUSTED_PROXIES config. Limits key on c.IP(), which behind a TLS
  terminator is the proxy, collapsing every client into one bucket. When set,
  X-Forwarded-For is honoured only from those proxies so the header can't be
  spoofed to dodge the limit. Logs a warning when unset.

Transactions:
- Check the error on all 51 previously-unchecked tx.Save/Create/Delete/
  Model(...).Update/Commit calls across 6 controllers. A failed write inside
  a transaction was silently ignored and the request still reported success;
  an unchecked Commit could fail with the caller told everything worked.
  Each site now rolls back and returns a specific message.

Pagination:
- Add utils.ParsePage/Paginated, reusing the pageno/pagesize convention
  GetAdminBookings already established. Default 500, hard cap 1000.
- Apply to the previously unbounded consignments, tripsheets, exceptions,
  app-users and clients endpoints. Defaults are high so existing consoles
  that don't paginate keep working; the cap only stops a growing table from
  being loaded wholesale. total is now a real COUNT, not len(data).
- GetClients also loaded the entire auth table to join in memory; it now
  fetches only the current page's rows.

Tests (first in the repo):
- Extract the hyperlocal pincode rule out of BookingPickupComplete into
  isHyperlocal so it is testable, covering the short/empty pincode fallback.
- Cover calculateVolumetricWeight and the ParsePage clamping rules.

Repo hygiene:
- Tag scratch/*.go with //go:build ignore. Each declared its own main(), so
  `go build ./...` failed on redeclaration; it now passes repo-wide.
- Untrack scratch/node_modules (216 files) and ignore node_modules, test
  artifacts, and the `doormile` binary `go build .` emits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-07-27 12:13:39 +05:30
parent a2b9268189
commit 2c26cbe4ba
240 changed files with 753 additions and 76753 deletions

71
utils/pagination.go Normal file
View File

@@ -0,0 +1,71 @@
package utils
import (
"math"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
const (
// DefaultPageSize bounds a list request that doesn't ask for a specific
// window. It is deliberately high: these endpoints previously returned
// whole tables, so a small default would silently truncate results for
// consoles that don't paginate yet. It exists to stop a growing table
// from being loaded into memory wholesale, not to enforce a page size.
DefaultPageSize = 500
// MaxPageSize is the ceiling a caller can request, so a client can't opt
// back into an unbounded scan by sending pagesize=999999.
MaxPageSize = 1000
)
// Page is a validated pagination window. Build one with ParsePage rather than
// constructing it directly.
type Page struct {
No int // 1-based page number
Size int
Offset int
}
// ParsePage reads ?pageno and ?pagesize off the request, matching the naming
// GetAdminBookings already established. Both are clamped into a safe range;
// absent or malformed values fall back to page 1 at DefaultPageSize rather
// than erroring, so a bad query string degrades instead of failing the call.
func ParsePage(c *fiber.Ctx) Page {
no := c.QueryInt("pageno", 1)
if no < 1 {
no = 1
}
size := c.QueryInt("pagesize", DefaultPageSize)
if size < 1 {
size = DefaultPageSize
}
if size > MaxPageSize {
size = MaxPageSize
}
return Page{No: no, Size: size, Offset: (no - 1) * size}
}
// Apply scopes a query to this page's window.
func (p Page) Apply(q *gorm.DB) *gorm.DB {
return q.Offset(p.Offset).Limit(p.Size)
}
// Paginated writes one page of a larger result set, using the same response
// keys GetAdminBookings already returns. It keeps the {success, data, total}
// shape utils.List emits and only adds keys, so clients that ignore the new
// fields are unaffected. total is the count of all matching rows, not the
// length of data.
func Paginated(c *fiber.Ctx, data interface{}, total int64, p Page) error {
return c.JSON(fiber.Map{
"success": true,
"data": data,
"total": total,
"pageno": p.No,
"pagesize": p.Size,
"pages": int(math.Ceil(float64(total) / float64(p.Size))),
})
}