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:
83
main.go
83
main.go
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -18,9 +20,38 @@ import (
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// errorHandler converts anything a handler returns — including a panic already
|
||||
// turned into an error by the recover middleware — into the same
|
||||
// {success, message} envelope the utils helpers emit, so clients never receive
|
||||
// Fiber's default plain-text error body.
|
||||
func errorHandler(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
msg := "internal server error"
|
||||
|
||||
var fe *fiber.Error
|
||||
if errors.As(err, &fe) {
|
||||
code = fe.Code
|
||||
msg = fe.Message
|
||||
}
|
||||
|
||||
// 5xx means we broke, not the caller — log it with the route for triage.
|
||||
if code >= fiber.StatusInternalServerError {
|
||||
utils.Error("request failed",
|
||||
"method", c.Method(),
|
||||
"path", c.Path(),
|
||||
"status", code,
|
||||
"error", err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(fiber.Map{"success": false, "message": msg})
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 1. Load configuration env variables
|
||||
_ = godotenv.Load()
|
||||
@@ -43,9 +74,36 @@ func main() {
|
||||
}
|
||||
|
||||
// 4. Initialize Fiber App
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "Doormile Logistics Service API v1",
|
||||
})
|
||||
fiberCfg := fiber.Config{
|
||||
AppName: "Doormile Logistics Service API v1",
|
||||
ErrorHandler: errorHandler,
|
||||
}
|
||||
|
||||
// Rate limiting keys on c.IP(). Behind a TLS-terminating reverse proxy the
|
||||
// socket peer is the proxy, so without this every caller shares a single
|
||||
// limit bucket and the whole fleet gets throttled together. Only honour
|
||||
// X-Forwarded-For from proxies we explicitly trust — otherwise any client
|
||||
// could spoof the header to dodge the limit entirely.
|
||||
if cfg.TrustedProxies != "" {
|
||||
proxies := strings.Split(cfg.TrustedProxies, ",")
|
||||
for i := range proxies {
|
||||
proxies[i] = strings.TrimSpace(proxies[i])
|
||||
}
|
||||
fiberCfg.EnableTrustedProxyCheck = true
|
||||
fiberCfg.TrustedProxies = proxies
|
||||
fiberCfg.ProxyHeader = fiber.HeaderXForwardedFor
|
||||
utils.Info("Trusting X-Forwarded-For from configured proxies", "proxies", proxies)
|
||||
} else {
|
||||
utils.Warn("TRUSTED_PROXIES is unset — rate limits key on the socket peer address. " +
|
||||
"If this service runs behind a reverse proxy, set TRUSTED_PROXIES or all clients will share one limit bucket.")
|
||||
}
|
||||
|
||||
app := fiber.New(fiberCfg)
|
||||
|
||||
// Panic recovery — must be the outermost middleware so it also catches
|
||||
// panics raised inside the ones registered below. Without this a single
|
||||
// nil-pointer dereference in any handler takes the whole process down.
|
||||
app.Use(recover.New(recover.Config{EnableStackTrace: true}))
|
||||
|
||||
// CORS policy
|
||||
app.Use(cors.New(cors.Config{
|
||||
@@ -58,6 +116,25 @@ func main() {
|
||||
// Structured Zap logger middleware
|
||||
app.Use(middlewares.ZapLogger())
|
||||
|
||||
// Global per-IP rate limit. Deliberately generous — this is an abuse
|
||||
// backstop, not a quota. Auth endpoints get a much tighter limit of their
|
||||
// own in routes.go. Health probes and websocket upgrades are exempt so
|
||||
// orchestrator checks and long-lived tracking sockets are never throttled.
|
||||
app.Use(limiter.New(limiter.Config{
|
||||
Max: 300,
|
||||
Expiration: 1 * time.Minute,
|
||||
Next: func(c *fiber.Ctx) bool {
|
||||
p := c.Path()
|
||||
return strings.HasSuffix(p, "/health") ||
|
||||
strings.HasSuffix(p, "/ready") ||
|
||||
strings.HasPrefix(p, "/ws/")
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).
|
||||
JSON(fiber.Map{"success": false, "message": "too many requests, please slow down"})
|
||||
},
|
||||
}))
|
||||
|
||||
// 5. Register routes
|
||||
routes.RegisterRoutes(app, cfg)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user