package api import ( "context" "encoding/json" "fmt" "io/fs" "net/http" "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" ) // 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 } // 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)) // Setup WhatsApp API routes on main router (these use their own Auth middleware) SetupWhatsAppRoutes(router, wh) // Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD) restheadspec.SetupMuxRoutes(apiV1Router, handler, nil) // Add custom routes (login, logout, etc.) on main router SetupCustomRoutes(router, secProvider, db) // Serve React SPA from the embedded filesystem at /ui/ distFS, err := fs.Sub(serverembed.RootEmbedFS, "dist") if err != nil { return nil, fmt.Errorf("failed to sub embedded dist FS: %w", err) } 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 } // 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.ModelPublicUser{}) 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) { 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") // Static files (no auth required) router.PathPrefix("/static/").HandlerFunc(h.ServeStatic) // 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/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") // 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 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) } } // handleQueryList lists records from a table func handleQueryList(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) { // Get model registry to find the model registry := getModelForTable(req.Table) if registry == nil { http.Error(w, "Table not found", http.StatusNotFound) return } // Create slice to hold results results := registry() // Build query query := db.NewSelect().Model(results) // Apply filters for key, value := range req.Filters { query = query.Where("? = ?", bun.Ident(key), value) } // Apply limit/offset if req.Limit > 0 { query = query.Limit(req.Limit) } if req.Offset > 0 { query = query.Offset(req.Offset) } // Execute query if err := query.Scan(r.Context()); err != nil { http.Error(w, fmt.Sprintf("Query failed: %v", err), 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) } } } // 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.ModelPublicUser: 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 } // Convert data map to model 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 } // Update in database _, err = db.NewUpdate().Model(model).Where("id = ?", req.ID).Exec(r.Context()) if err != nil { http.Error(w, fmt.Sprintf("Update 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.ModelPublicUser{} } 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.ModelPublicUser{} 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) }) }