277 lines
9.9 KiB
Go
277 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"doormile/config"
|
|
"doormile/controllers"
|
|
"doormile/db"
|
|
"doormile/internal/ai/playground"
|
|
"doormile/internal/ai/telemetry"
|
|
"doormile/internal/assignment"
|
|
"doormile/internal/notify"
|
|
"doormile/internal/routing"
|
|
"doormile/internal/sms"
|
|
"doormile/internal/worker"
|
|
"doormile/middlewares"
|
|
"doormile/migrations"
|
|
"doormile/routes"
|
|
"doormile/utils"
|
|
|
|
"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.
|
|
// isLoopbackOrigin reports whether an Origin header names this machine, on any
|
|
// port. It exists for Flutter Web, whose dev server picks a fresh random port
|
|
// on every launch — no fixed allowlist can name it in advance.
|
|
//
|
|
// Deliberately strict about what counts as loopback: the host must be exactly
|
|
// localhost, 127.0.0.1 or [::1]. A prefix match would admit
|
|
// http://localhost.attacker.com, which is a different machine entirely and is
|
|
// precisely the mistake this kind of check usually makes.
|
|
func isLoopbackOrigin(origin string) bool {
|
|
u, err := url.Parse(origin)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return false
|
|
}
|
|
switch u.Hostname() {
|
|
case "localhost", "127.0.0.1", "::1":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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()
|
|
cfg := config.Load()
|
|
|
|
// Refuse to boot production on a committed fallback secret. Checked before
|
|
// anything connects, so a misconfigured deploy fails loudly at start rather
|
|
// than serving traffic with a JWT secret that is in the git history.
|
|
if missing := cfg.MissingProductionSecrets(); len(missing) > 0 {
|
|
utils.Error("Refusing to start: required secrets are not set in production", "missing", strings.Join(missing, ","))
|
|
os.Exit(1)
|
|
}
|
|
|
|
utils.Info("Starting Doormile Backend...")
|
|
|
|
// 2. Connect to Postgres, Redis & NATS
|
|
db.Connect(cfg)
|
|
db.InitRedis(cfg)
|
|
db.InitNATS(cfg)
|
|
notify.InitFCM()
|
|
|
|
// Install the SMS gateway. Until this call existed, sms.Register had no
|
|
// callers anywhere in the tree, so every customer verification code was
|
|
// written to this log and no text was ever sent — the single blocker on
|
|
// customer sign-in, and the reason bookings were being made in the app's
|
|
// offline mode and never reaching the console.
|
|
sms.Configure()
|
|
|
|
// 3. Run GORM migrations for logistics tables
|
|
if db.DB != nil {
|
|
err := migrations.Migrate(db.DB)
|
|
if err != nil {
|
|
utils.Error("⚠️ Migration failed, continuing server boot...", "error", err.Error())
|
|
}
|
|
}
|
|
|
|
// 4. Initialize Fiber App
|
|
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.
|
|
//
|
|
// The named list is production and the well-known dev-server ports. It cannot
|
|
// cover local development on its own: `flutter run -d chrome` binds a RANDOM
|
|
// high port on every launch (65256 one run, something else the next), so a
|
|
// fixed allowlist misses it every time and the browser rejects the request
|
|
// with PreflightMissingAllowOriginHeader before the handler is ever reached.
|
|
//
|
|
// AllowOriginsFunc is consulted only when the static list has already missed,
|
|
// so it widens nothing in production — it just admits loopback origins on any
|
|
// port while developing. It is NOT enabled when ENV=production: a live API
|
|
// that accepts credentialed requests from any localhost page is a real, if
|
|
// modest, hole — a developer visiting a hostile page served from their own
|
|
// machine would have that page able to call this API as them.
|
|
//
|
|
// A wildcard is not an option regardless: AllowCredentials with
|
|
// AllowOrigins "*" is rejected by the CORS spec, and Fiber panics on it.
|
|
allowLoopbackOrigins := !strings.EqualFold(cfg.Env, "production")
|
|
if allowLoopbackOrigins {
|
|
utils.Info("CORS: loopback origins on any port are allowed (non-production)", "env", cfg.Env)
|
|
}
|
|
|
|
app.Use(cors.New(cors.Config{
|
|
// Idempotency-Key is sent by the rider app on pickup-complete, payment
|
|
// and the base handover. A browser client that could not send it would
|
|
// lose retry safety on exactly the calls that most need it.
|
|
AllowHeaders: "Origin,Content-Type,Accept,Authorization,Idempotency-Key",
|
|
AllowOrigins: "http://localhost:5173,http://localhost:5174,http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:8080,http://localhost:8081,https://doormile.com,https://www.doormile.com,https://admin.doormile.com,https://api.doormile.com,https://crm.doormile.com,https://console.doormile.com,https://app.doormile.com,https://hub.doormile.com",
|
|
AllowOriginsFunc: func(origin string) bool {
|
|
return allowLoopbackOrigins && isLoopbackOrigin(origin)
|
|
},
|
|
AllowCredentials: true,
|
|
AllowMethods: "GET,POST,PUT,DELETE,PATCH,OPTIONS",
|
|
}))
|
|
|
|
// Echo X-Request-Id on every response, errors included, minting one when the
|
|
// caller did not send it. The customer app funnels every failure into a
|
|
// single retryable error state, so without this a support conversation
|
|
// about "it failed" has nothing to correlate against the logs. Registered
|
|
// before the logger so the id is available to it.
|
|
app.Use(middlewares.RequestID())
|
|
|
|
// 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)
|
|
|
|
// 6. Pre-warm Redis pricing cache from Postgres
|
|
controllers.WarmPricingCache()
|
|
|
|
// 7. Start NATS booking worker in background
|
|
go worker.StartBookingWorker()
|
|
|
|
// 8. Start the assignment worker. Every replica runs one; they share a
|
|
// durable consumer, so JetStream hands each booking to exactly one of them.
|
|
go assignment.StartAssignmentWorker()
|
|
|
|
// 9. Point the stop sequencer at the Route Optimization API.
|
|
routing.BaseURL = cfg.RouteOptimizerURL
|
|
|
|
// 10. Record AI_engine agent runs (telemetry.task) and live state
|
|
// (telemetry.agent). Observability only: a nil NATS connection logs and
|
|
// skips, and nothing here can block a request.
|
|
if db.DB != nil {
|
|
telemetry.NewRecorder(db.DB, db.Rdb).Start(db.Nc)
|
|
}
|
|
|
|
// Agent Studio Test playground: switched on only when a model API key is
|
|
// configured. Without one the endpoint answers 503 and the console says so.
|
|
if cfg.PlaygroundLLMAPIKey != "" {
|
|
controllers.PlaygroundModel = playground.NewOpenAICompat(cfg.PlaygroundLLMBaseURL, cfg.PlaygroundLLMAPIKey, cfg.PlaygroundLLMModel)
|
|
utils.Info("ai playground: enabled", "base_url", cfg.PlaygroundLLMBaseURL, "model", cfg.PlaygroundLLMModel)
|
|
}
|
|
|
|
// 7. Startup server in a background thread
|
|
go func() {
|
|
utils.Info("Server starting", "port", cfg.Port)
|
|
if err := app.Listen(":" + cfg.Port); err != nil {
|
|
utils.Logger.Fatalf("Server failed to bind: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Graceful shutdown listener
|
|
gracefulShutdown(app)
|
|
}
|
|
|
|
func gracefulShutdown(app *fiber.App) {
|
|
c := make(chan os.Signal, 1)
|
|
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
|
|
<-c
|
|
utils.Info("Shutting down Doormile Backend...")
|
|
|
|
// Shutdown fiber
|
|
if err := app.Shutdown(); err != nil {
|
|
utils.Error("Fiber shutdown failed", "error", err)
|
|
}
|
|
|
|
// Close database pools
|
|
db.CloseDB()
|
|
|
|
time.Sleep(1 * time.Second)
|
|
utils.Info("Shutdown completed cleanly.")
|
|
}
|