package api import ( "bufio" "context" "encoding/json" "fmt" "io/fs" "math" "net/http" "os" "runtime" "strconv" "strings" "sync" "time" "github.com/google/uuid" "git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/handlers" "git.warky.dev/wdevs/whatshooked/pkg/models" "git.warky.dev/wdevs/whatshooked/pkg/serverembed" "git.warky.dev/wdevs/whatshooked/pkg/storage" "github.com/bitechdev/ResolveSpec/pkg/common" "github.com/bitechdev/ResolveSpec/pkg/common/adapters/database" "github.com/bitechdev/ResolveSpec/pkg/modelregistry" "github.com/bitechdev/ResolveSpec/pkg/restheadspec" "github.com/bitechdev/ResolveSpec/pkg/security" "github.com/bitechdev/ResolveSpec/pkg/server" "github.com/gorilla/mux" "github.com/uptrace/bun" "golang.org/x/crypto/bcrypt" ) // WhatsHookedInterface defines the interface for accessing WhatsHooked components type WhatsHookedInterface interface { Handlers() *handlers.Handlers } // Server represents the API server type Server struct { serverMgr server.Manager handler *restheadspec.Handler secProvider security.SecurityProvider db *bun.DB config *config.Config wh WhatsHookedInterface } type systemStatsSampler struct { mu sync.Mutex lastCPUJiffies uint64 lastCPUTimestamp time.Time lastNetTotal uint64 lastNetTimestamp time.Time } var runtimeStatsSampler = &systemStatsSampler{} // NewServer creates a new API server with ResolveSpec integration func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server, error) { // Create model registry and register models registry := modelregistry.NewModelRegistry() registerModelsToRegistry(registry) // Create BUN adapter bunAdapter := database.NewBunAdapter(db) // Create ResolveSpec handler with registry handler := restheadspec.NewHandler(bunAdapter, registry) // Create security provider secProvider := NewSecurityProvider(cfg.Server.JWTSecret, db, cfg) // Create security list and register hooks securityList, err := security.NewSecurityList(secProvider) if err != nil { return nil, fmt.Errorf("failed to create security list: %w", err) } restheadspec.RegisterSecurityHooks(handler, securityList) // Create router router := mux.NewRouter() // Add CORS middleware to all routes router.Use(corsMiddleware) // Create a subrouter for /api/v1/* routes that need JWT authentication apiV1Router := router.PathPrefix("/api/v1").Subrouter() apiV1Router.Use(security.NewAuthMiddleware(securityList)) apiV1Router.Use(security.SetSecurityMiddleware(securityList)) // Create the embedded dist FS (built Vite output, includes web/public/ contents) distFS, err := fs.Sub(serverembed.RootEmbedFS, "dist") if err != nil { return nil, fmt.Errorf("failed to sub embedded dist FS: %w", err) } // Setup WhatsApp API routes on main router (these use their own Auth middleware) SetupWhatsAppRoutes(router, wh, distFS) // Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD) restheadspec.SetupMuxRoutes(apiV1Router, handler, nil) apiV1Router.HandleFunc("/system/stats", handleSystemStats).Methods("GET") apiV1Router.HandleFunc("/cache/stats", wh.Handlers().GetCacheStats).Methods("GET") // Add custom routes (login, logout, etc.) on main router SetupCustomRoutes(router, secProvider, db) // Serve React SPA from the embedded filesystem at /ui/ spa := embeddedSPAHandler{fs: distFS, indexPath: "index.html"} router.PathPrefix("/ui/").Handler(http.StripPrefix("/ui", spa)) router.PathPrefix("/ui").Handler(http.StripPrefix("/ui", spa)) // Create server manager serverMgr := server.NewManager() // Add HTTP server _, err = serverMgr.Add(server.Config{ Name: "whatshooked", Host: cfg.Server.Host, Port: cfg.Server.Port, Handler: router, GZIP: true, ShutdownTimeout: 30 * time.Second, DrainTimeout: 25 * time.Second, }) if err != nil { return nil, fmt.Errorf("failed to add server: %w", err) } // Register shutdown callback for database serverMgr.RegisterShutdownCallback(func(ctx context.Context) error { return storage.Close() }) return &Server{ serverMgr: serverMgr, handler: handler, secProvider: secProvider, db: db, config: cfg, wh: wh, }, nil } func handleSystemStats(w http.ResponseWriter, _ *http.Request) { var mem runtime.MemStats runtime.ReadMemStats(&mem) cpuPercent := runtimeStatsSampler.sampleCPUPercent() rxBytes, txBytes := readNetworkBytes() netTotal := rxBytes + txBytes netBytesPerSec := runtimeStatsSampler.sampleNetworkBytesPerSec(netTotal) writeJSON(w, http.StatusOK, map[string]any{ "go_memory_bytes": mem.Alloc, "go_memory_mb": float64(mem.Alloc) / (1024.0 * 1024.0), "go_sys_memory_bytes": mem.Sys, "go_sys_memory_mb": float64(mem.Sys) / (1024.0 * 1024.0), "go_goroutines": runtime.NumGoroutine(), "go_cpu_percent": cpuPercent, "network_rx_bytes": rxBytes, "network_tx_bytes": txBytes, "network_total_bytes": netTotal, "network_bytes_per_sec": netBytesPerSec, }) } func readProcessCPUJiffies() (uint64, bool) { data, err := os.ReadFile("/proc/self/stat") if err != nil { return 0, false } parts := strings.Fields(string(data)) if len(parts) < 15 { return 0, false } utime, err := strconv.ParseUint(parts[13], 10, 64) if err != nil { return 0, false } stime, err := strconv.ParseUint(parts[14], 10, 64) if err != nil { return 0, false } return utime + stime, true } func (s *systemStatsSampler) sampleCPUPercent() float64 { const clockTicksPerSecond = 100.0 // Linux default USER_HZ jiffies, ok := readProcessCPUJiffies() if !ok { return 0 } now := time.Now() s.mu.Lock() defer s.mu.Unlock() if s.lastCPUTimestamp.IsZero() || s.lastCPUJiffies == 0 { s.lastCPUTimestamp = now s.lastCPUJiffies = jiffies return 0 } elapsed := now.Sub(s.lastCPUTimestamp).Seconds() deltaJiffies := jiffies - s.lastCPUJiffies s.lastCPUTimestamp = now s.lastCPUJiffies = jiffies if elapsed <= 0 { return 0 } cpuSeconds := float64(deltaJiffies) / clockTicksPerSecond cores := float64(runtime.NumCPU()) if cores < 1 { cores = 1 } percent := (cpuSeconds / elapsed) * 100.0 / cores if percent < 0 { return 0 } if percent > 100 { return 100 } return math.Round(percent*100) / 100 } func readNetworkBytes() (uint64, uint64) { file, err := os.Open("/proc/net/dev") if err != nil { return 0, 0 } defer file.Close() var rxBytes uint64 var txBytes uint64 scanner := bufio.NewScanner(file) lineNo := 0 for scanner.Scan() { lineNo++ // Skip headers. if lineNo <= 2 { continue } line := strings.TrimSpace(scanner.Text()) if line == "" { continue } parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue } iface := strings.TrimSpace(parts[0]) if iface == "lo" { continue } fields := strings.Fields(parts[1]) if len(fields) < 16 { continue } rx, err := strconv.ParseUint(fields[0], 10, 64) if err == nil { rxBytes += rx } tx, err := strconv.ParseUint(fields[8], 10, 64) if err == nil { txBytes += tx } } return rxBytes, txBytes } func (s *systemStatsSampler) sampleNetworkBytesPerSec(totalBytes uint64) float64 { now := time.Now() s.mu.Lock() defer s.mu.Unlock() if s.lastNetTimestamp.IsZero() { s.lastNetTimestamp = now s.lastNetTotal = totalBytes return 0 } elapsed := now.Sub(s.lastNetTimestamp).Seconds() deltaBytes := totalBytes - s.lastNetTotal s.lastNetTimestamp = now s.lastNetTotal = totalBytes if elapsed <= 0 { return 0 } bps := float64(deltaBytes) / elapsed if bps < 0 { return 0 } return math.Round(bps*100) / 100 } // Start starts the API server func (s *Server) Start() error { return s.serverMgr.ServeWithGracefulShutdown() } // registerModelsToRegistry registers all BUN models with the model registry func registerModelsToRegistry(registry common.ModelRegistry) { // Register all models with their table names (without schema for SQLite compatibility) registry.RegisterModel("users", &models.ModelPublicUsers{}) registry.RegisterModel("api_keys", &models.ModelPublicAPIKey{}) registry.RegisterModel("hooks", &models.ModelPublicHook{}) registry.RegisterModel("whatsapp_accounts", &models.ModelPublicWhatsappAccount{}) registry.RegisterModel("event_logs", &models.ModelPublicEventLog{}) registry.RegisterModel("sessions", &models.ModelPublicSession{}) registry.RegisterModel("message_cache", &models.ModelPublicMessageCache{}) } // SetupWhatsAppRoutes adds all WhatsApp API routes func SetupWhatsAppRoutes(router *mux.Router, wh WhatsHookedInterface, distFS fs.FS) { h := wh.Handlers() // Landing page (no auth required) router.HandleFunc("/", h.ServeIndex).Methods("GET") // Privacy policy and terms of service (no auth required) router.HandleFunc("/privacy-policy", h.ServePrivacyPolicy).Methods("GET") router.HandleFunc("/terms-of-service", h.ServeTermsOfService).Methods("GET") // Logo files served from the embedded pkg/handlers/static/ (referenced by index.html) router.HandleFunc("/static/logo.png", h.ServeStatic) router.HandleFunc("/static/logo1024.png", h.ServeStatic) // Everything else under /static/ is served from the built web/public/ directory router.PathPrefix("/static/").Handler(http.StripPrefix("/static", http.FileServer(http.FS(distFS)))) // Health check (no auth required) router.HandleFunc("/health", h.Health).Methods("GET") // Hook management (with auth) router.HandleFunc("/api/hooks", h.Auth(h.Hooks)) router.HandleFunc("/api/hooks/add", h.Auth(h.AddHook)).Methods("POST") router.HandleFunc("/api/hooks/remove", h.Auth(h.RemoveHook)).Methods("POST") // Account management (with auth) router.HandleFunc("/api/accounts", h.Auth(h.Accounts)) router.HandleFunc("/api/accounts/status", h.Auth(h.AccountStatuses)).Methods("GET") router.HandleFunc("/api/accounts/add", h.Auth(h.AddAccount)).Methods("POST") router.HandleFunc("/api/accounts/update", h.Auth(h.UpdateAccount)).Methods("POST") router.HandleFunc("/api/accounts/remove", h.Auth(h.RemoveAccount)).Methods("POST") router.HandleFunc("/api/accounts/disable", h.Auth(h.DisableAccount)).Methods("POST") router.HandleFunc("/api/accounts/enable", h.Auth(h.EnableAccount)).Methods("POST") // Send messages (with auth) router.HandleFunc("/api/send", h.Auth(h.SendMessage)).Methods("POST") router.HandleFunc("/api/send/image", h.Auth(h.SendImage)).Methods("POST") router.HandleFunc("/api/send/video", h.Auth(h.SendVideo)).Methods("POST") router.HandleFunc("/api/send/document", h.Auth(h.SendDocument)).Methods("POST") router.HandleFunc("/api/send/audio", h.Auth(h.SendAudio)).Methods("POST") router.HandleFunc("/api/send/sticker", h.Auth(h.SendSticker)).Methods("POST") router.HandleFunc("/api/send/location", h.Auth(h.SendLocation)).Methods("POST") router.HandleFunc("/api/send/contacts", h.Auth(h.SendContacts)).Methods("POST") router.HandleFunc("/api/send/interactive", h.Auth(h.SendInteractive)).Methods("POST") router.HandleFunc("/api/send/template", h.Auth(h.SendTemplate)).Methods("POST") router.HandleFunc("/api/send/flow", h.Auth(h.SendFlow)).Methods("POST") router.HandleFunc("/api/send/reaction", h.Auth(h.SendReaction)).Methods("POST") router.HandleFunc("/api/send/catalog", h.Auth(h.SendCatalogMessage)).Methods("POST") router.HandleFunc("/api/send/product", h.Auth(h.SendSingleProduct)).Methods("POST") router.HandleFunc("/api/send/product-list", h.Auth(h.SendProductList)).Methods("POST") // Message operations (with auth) router.HandleFunc("/api/messages/read", h.Auth(h.MarkAsRead)).Methods("POST") // Serve media files (with auth) router.PathPrefix("/api/media/").HandlerFunc(h.ServeMedia) // Serve QR codes (no auth - needed during pairing) router.PathPrefix("/api/qr/").HandlerFunc(h.ServeQRCode) // Business API webhooks (no auth - Meta validates via verify_token) router.PathPrefix("/webhooks/whatsapp/").HandlerFunc(h.BusinessAPIWebhook) // Template management (with auth) router.HandleFunc("/api/templates", h.Auth(h.ListTemplates)) router.HandleFunc("/api/templates/upload", h.Auth(h.UploadTemplate)).Methods("POST") router.HandleFunc("/api/templates/delete", h.Auth(h.DeleteTemplate)).Methods("POST") // Flow management (with auth) router.HandleFunc("/api/flows", h.Auth(h.ListFlows)) router.HandleFunc("/api/flows/create", h.Auth(h.CreateFlow)).Methods("POST") router.HandleFunc("/api/flows/get", h.Auth(h.GetFlow)) router.HandleFunc("/api/flows/upload", h.Auth(h.UploadFlowAsset)).Methods("POST") router.HandleFunc("/api/flows/publish", h.Auth(h.PublishFlow)).Methods("POST") router.HandleFunc("/api/flows/deprecate", h.Auth(h.DeprecateFlow)).Methods("POST") router.HandleFunc("/api/flows/delete", h.Auth(h.DeleteFlow)).Methods("POST") // Phone number management (with auth) router.HandleFunc("/api/phone-numbers", h.Auth(h.ListPhoneNumbers)) router.HandleFunc("/api/phone-numbers/request-code", h.Auth(h.RequestVerificationCode)).Methods("POST") router.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode)).Methods("POST") router.HandleFunc("/api/phone-numbers/register", h.Auth(h.RegisterPhoneNumber)).Methods("POST") // Media management (with auth) router.HandleFunc("/api/media/upload", h.Auth(h.UploadMedia)).Methods("POST") router.HandleFunc("/api/media-delete", h.Auth(h.DeleteMediaFile)).Methods("POST") // Business profile (with auth) router.HandleFunc("/api/business-profile", h.Auth(h.GetBusinessProfile)) router.HandleFunc("/api/business-profile/update", h.Auth(h.UpdateBusinessProfile)).Methods("POST") // Catalog / commerce (with auth) router.HandleFunc("/api/catalogs", h.Auth(h.ListCatalogs)) router.HandleFunc("/api/catalogs/products", h.Auth(h.ListProducts)) // Message cache management (with auth) router.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)).Methods("GET") router.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)).Methods("GET") router.HandleFunc("/api/cache/replay", h.Auth(h.ReplayCachedEvents)).Methods("POST") router.HandleFunc("/api/cache/event", h.Auth(h.GetCachedEvent)).Methods("GET") router.HandleFunc("/api/cache/event/replay", h.Auth(h.ReplayCachedEvent)).Methods("POST") router.HandleFunc("/api/cache/event/delete", h.Auth(h.DeleteCachedEvent)).Methods("DELETE") router.HandleFunc("/api/cache/clear", h.Auth(h.ClearCache)).Methods("DELETE") } // SetupCustomRoutes adds custom authentication and management routes func SetupCustomRoutes(router *mux.Router, secProvider security.SecurityProvider, db *bun.DB) { // Health check endpoint router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"healthy"}`)) }).Methods("GET") // Login endpoint router.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) { handleLogin(w, r, secProvider) }).Methods("POST", "OPTIONS") // Logout endpoint router.HandleFunc("/api/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) { handleLogout(w, r, secProvider) }).Methods("POST", "OPTIONS") // Unified query endpoint for ResolveSpec-style queries router.HandleFunc("/api/v1/query", func(w http.ResponseWriter, r *http.Request) { handleQuery(w, r, db, secProvider) }).Methods("POST", "OPTIONS") } // handleLogin handles user login func handleLogin(w http.ResponseWriter, r *http.Request, secProvider security.SecurityProvider) { var req security.LoginRequest if err := parseJSON(r, &req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } resp, err := secProvider.Login(r.Context(), req) if err != nil { http.Error(w, "Invalid credentials", http.StatusUnauthorized) return } writeJSON(w, http.StatusOK, resp) } // handleLogout handles user logout func handleLogout(w http.ResponseWriter, r *http.Request, secProvider security.SecurityProvider) { token := extractToken(r) if token == "" { http.Error(w, "No token provided", http.StatusBadRequest) return } req := security.LogoutRequest{Token: token} if err := secProvider.Logout(r.Context(), req); err != nil { http.Error(w, "Logout failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{"message": "Logged out successfully"}) } // QueryRequest represents a unified query request type QueryRequest struct { Action string `json:"action"` // "list", "get", "create", "update", "delete" Table string `json:"table"` // Table name (e.g., "users", "hooks") ID string `json:"id,omitempty"` // For get/update/delete Data map[string]interface{} `json:"data,omitempty"` // For create/update Filters map[string]interface{} `json:"filters,omitempty"` // For list — exact match Search string `json:"search,omitempty"` // For list — LIKE across SearchColumns SearchColumns []string `json:"search_columns,omitempty"` // Columns to apply Search against OrderBy string `json:"order_by,omitempty"` // Column to order by OrderDir string `json:"order_dir,omitempty"` // "ASC" or "DESC" Limit int `json:"limit,omitempty"` Offset int `json:"offset,omitempty"` } // handleQuery handles unified query requests func handleQuery(w http.ResponseWriter, r *http.Request, db *bun.DB, secProvider security.SecurityProvider) { // Authenticate request userCtx, err := secProvider.Authenticate(r) if err != nil { http.Error(w, "Authentication required", http.StatusUnauthorized) return } var req QueryRequest if err := parseJSON(r, &req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Execute query based on action switch req.Action { case "list": handleQueryList(w, r, db, req, userCtx) case "get": handleQueryGet(w, r, db, req, userCtx) case "create": handleQueryCreate(w, r, db, req, userCtx) case "update": handleQueryUpdate(w, r, db, req, userCtx) case "delete": handleQueryDelete(w, r, db, req, userCtx) default: http.Error(w, "Invalid action", http.StatusBadRequest) } } // applySearchAndFilters applies exact-match filters and LIKE search to a select query. func applySearchAndFilters(query *bun.SelectQuery, req QueryRequest) *bun.SelectQuery { for key, value := range req.Filters { query = query.Where("? = ?", bun.Ident(key), value) } if req.Search != "" && len(req.SearchColumns) > 0 { query = query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery { for i, col := range req.SearchColumns { if i == 0 { q = q.Where("? LIKE ?", bun.Ident(col), "%"+req.Search+"%") } else { q = q.WhereOr("? LIKE ?", bun.Ident(col), "%"+req.Search+"%") } } return q }) } return query } // handleQueryList lists records from a table func handleQueryList(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) { registry := getModelForTable(req.Table) if registry == nil { http.Error(w, "Table not found", http.StatusNotFound) return } results := registry() query := db.NewSelect().Model(results) query = applySearchAndFilters(query, req) // Apply ordering if req.OrderBy != "" { dir := "ASC" if strings.ToUpper(req.OrderDir) == "DESC" { dir = "DESC" } query = query.OrderExpr("? "+dir, bun.Ident(req.OrderBy)) } // Apply limit/offset if req.Limit > 0 { query = query.Limit(req.Limit) } if req.Offset > 0 { query = query.Offset(req.Offset) } if err := query.Scan(r.Context()); err != nil { http.Error(w, "Query failed", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, results) } // handleQueryGet retrieves a single record by ID func handleQueryGet(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) { model := getModelSingleForTable(req.Table) if model == nil { http.Error(w, "Table not found", http.StatusNotFound) return } err := db.NewSelect().Model(model).Where("id = ?", req.ID).Scan(r.Context()) if err != nil { http.Error(w, "Record not found", http.StatusNotFound) return } writeJSON(w, http.StatusOK, model) } // handleQueryCreate creates a new record func handleQueryCreate(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) { model := getModelSingleForTable(req.Table) if model == nil { http.Error(w, "Table not found", http.StatusNotFound) return } // Initialize data map if needed if req.Data == nil { req.Data = make(map[string]interface{}) } // Auto-generate UUID for id field if not provided generatedID := "" if _, exists := req.Data["id"]; !exists { generatedID = uuid.New().String() req.Data["id"] = generatedID } // Auto-inject user_id for tables that need it if userCtx != nil && userCtx.Claims != nil { // Get user_id from claims (it's stored as UUID string) if userIDClaim, ok := userCtx.Claims["user_id"]; ok { if userID, ok := userIDClaim.(string); ok && userID != "" { // Add user_id to data if the table requires it and it's not already set tablesWithUserID := map[string]bool{ "hooks": true, "whatsapp_accounts": true, "api_keys": true, "event_logs": true, } if tablesWithUserID[req.Table] { // Only set user_id if not already provided if _, exists := req.Data["user_id"]; !exists { req.Data["user_id"] = userID } } } } } // Auto-generate session_path for WhatsApp accounts if not provided if req.Table == "whatsapp_accounts" { // Set session_path if not already provided if _, exists := req.Data["session_path"]; !exists { // Use account_id if provided, otherwise use generated id sessionID := "" if accountID, ok := req.Data["account_id"].(string); ok && accountID != "" { sessionID = accountID } else if generatedID != "" { sessionID = generatedID } else if id, ok := req.Data["id"].(string); ok && id != "" { sessionID = id } if sessionID != "" { req.Data["session_path"] = fmt.Sprintf("./sessions/%s", sessionID) } } } if req.Table == "users" { rawPassword, exists := req.Data["password"] if !exists { http.Error(w, "Password is required", http.StatusBadRequest) return } password, ok := rawPassword.(string) if !ok || password == "" { http.Error(w, "Password is required", http.StatusBadRequest) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { http.Error(w, "Failed to process password", http.StatusInternalServerError) return } req.Data["password"] = string(hashedPassword) } // Convert data map to model using JSON marshaling dataJSON, err := json.Marshal(req.Data) if err != nil { http.Error(w, "Invalid data", http.StatusBadRequest) return } if err := json.Unmarshal(dataJSON, model); err != nil { http.Error(w, "Invalid data format", http.StatusBadRequest) return } // Ensure ID is set after unmarshaling by using model-specific handling if generatedID != "" { switch m := model.(type) { case *models.ModelPublicWhatsappAccount: m.ID.FromString(generatedID) case *models.ModelPublicHook: m.ID.FromString(generatedID) case *models.ModelPublicAPIKey: m.ID.FromString(generatedID) case *models.ModelPublicEventLog: m.ID.FromString(generatedID) case *models.ModelPublicUsers: m.ID.FromString(generatedID) } } // Insert into database _, err = db.NewInsert().Model(model).Exec(r.Context()) if err != nil { http.Error(w, fmt.Sprintf("Create failed: %v", err), http.StatusInternalServerError) return } writeJSON(w, http.StatusCreated, model) } // handleQueryUpdate updates an existing record func handleQueryUpdate(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) { model := getModelSingleForTable(req.Table) if model == nil { http.Error(w, "Table not found", http.StatusNotFound) return } if req.Data == nil || len(req.Data) == 0 { http.Error(w, "No update data provided", http.StatusBadRequest) return } if req.Table == "users" { if rawPassword, exists := req.Data["password"]; exists { password, ok := rawPassword.(string) if !ok { http.Error(w, "Invalid password format", http.StatusBadRequest) return } if password == "" { delete(req.Data, "password") } else { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { http.Error(w, "Failed to process password", http.StatusInternalServerError) return } req.Data["password"] = string(hashedPassword) } } } updateQuery := db.NewUpdate().Model(model).Where("id = ?", req.ID) updatedColumns := 0 for column, value := range req.Data { // Protect immutable/audit columns from accidental overwrite. if column == "id" || column == "created_at" { continue } updateQuery = updateQuery.Set("? = ?", bun.Ident(column), value) updatedColumns++ } if updatedColumns == 0 { http.Error(w, "No mutable fields to update", http.StatusBadRequest) return } // Update only the provided fields. _, err := updateQuery.Exec(r.Context()) if err != nil { http.Error(w, fmt.Sprintf("Update failed: %v", err), http.StatusInternalServerError) return } // Return the latest database row after update. if err := db.NewSelect().Model(model).Where("id = ?", req.ID).Scan(r.Context()); err != nil { http.Error(w, fmt.Sprintf("Update succeeded but reload failed: %v", err), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, model) } // handleQueryDelete deletes a record func handleQueryDelete(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) { model := getModelSingleForTable(req.Table) if model == nil { http.Error(w, "Table not found", http.StatusNotFound) return } _, err := db.NewDelete().Model(model).Where("id = ?", req.ID).Exec(r.Context()) if err != nil { http.Error(w, fmt.Sprintf("Delete failed: %v", err), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{"message": "Deleted successfully"}) } // getModelForTable returns a function that creates a slice of models for the table func getModelForTable(table string) func() interface{} { switch table { case "users": return func() interface{} { return &[]models.ModelPublicUsers{} } case "hooks": return func() interface{} { return &[]models.ModelPublicHook{} } case "whatsapp_accounts": return func() interface{} { return &[]models.ModelPublicWhatsappAccount{} } case "event_logs": return func() interface{} { return &[]models.ModelPublicEventLog{} } case "api_keys": return func() interface{} { return &[]models.ModelPublicAPIKey{} } case "sessions": return func() interface{} { return &[]models.ModelPublicSession{} } case "message_cache": return func() interface{} { return &[]models.ModelPublicMessageCache{} } default: return nil } } // getModelSingleForTable returns a single model instance for the table func getModelSingleForTable(table string) interface{} { switch table { case "users": return &models.ModelPublicUsers{} case "hooks": return &models.ModelPublicHook{} case "whatsapp_accounts": return &models.ModelPublicWhatsappAccount{} case "event_logs": return &models.ModelPublicEventLog{} case "api_keys": return &models.ModelPublicAPIKey{} case "sessions": return &models.ModelPublicSession{} case "message_cache": return &models.ModelPublicMessageCache{} default: return nil } } // Helper functions func parseJSON(r *http.Request, v interface{}) error { decoder := json.NewDecoder(r.Body) return decoder.Decode(v) } func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func extractToken(r *http.Request) string { // Extract from Authorization header: "Bearer " auth := r.Header.Get("Authorization") if len(auth) > 7 && auth[:7] == "Bearer " { return auth[7:] } return "" } // embeddedSPAHandler serves a React SPA from an embedded fs.FS. // Static assets are served directly; all other paths fall back to index.html // to support client-side routing. type embeddedSPAHandler struct { fs fs.FS indexPath string } func (h embeddedSPAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Strip leading slash so fs.FS Open calls work correctly. if len(path) > 0 && path[0] == '/' { path = path[1:] } if path == "" { path = h.indexPath } // Try to open the requested path in the embedded FS. f, err := h.fs.Open(path) if err != nil { // Not found — serve index.html for client-side routing. r2 := r.Clone(r.Context()) r2.URL.Path = "/" + h.indexPath http.FileServer(http.FS(h.fs)).ServeHTTP(w, r2) return } defer f.Close() stat, err := f.Stat() if err != nil || stat.IsDir() { r2 := r.Clone(r.Context()) r2.URL.Path = "/" + h.indexPath http.FileServer(http.FS(h.fs)).ServeHTTP(w, r2) return } // Serve the real file. http.FileServer(http.FS(h.fs)).ServeHTTP(w, r) } // corsMiddleware adds CORS headers to allow frontend requests func corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Allow requests from any origin (adjust in production) w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") w.Header().Set("Access-Control-Allow-Credentials", "true") w.Header().Set("Access-Control-Max-Age", "86400") // Handle preflight requests if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) }