344 lines
13 KiB
Go
344 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"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/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()
|
|
|
|
// 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)
|
|
|
|
// Add static file serving for React app (must be last - catch-all route)
|
|
// Serve React app from web/dist directory
|
|
spa := spaHandler{staticPath: "web/dist", indexPath: "index.html"}
|
|
router.PathPrefix("/").Handler(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")
|
|
|
|
// Logout endpoint
|
|
router.HandleFunc("/api/v1/auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
|
handleLogout(w, r, secProvider)
|
|
}).Methods("POST")
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
// 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 <token>"
|
|
auth := r.Header.Get("Authorization")
|
|
if len(auth) > 7 && auth[:7] == "Bearer " {
|
|
return auth[7:]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// spaHandler implements the http.Handler interface for serving a SPA
|
|
type spaHandler struct {
|
|
staticPath string
|
|
indexPath string
|
|
}
|
|
|
|
// ServeHTTP inspects the URL path to locate a file within the static dir
|
|
// If a file is found, it is served. If not, the index.html file is served for client-side routing
|
|
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
// Get the path
|
|
path := r.URL.Path
|
|
|
|
// Check whether a file exists at the given path
|
|
info, err := http.Dir(h.staticPath).Open(path)
|
|
if err != nil {
|
|
// File does not exist, serve index.html for client-side routing
|
|
http.ServeFile(w, r, h.staticPath+"/"+h.indexPath)
|
|
return
|
|
}
|
|
defer info.Close()
|
|
|
|
// Check if path is a directory
|
|
stat, err := info.Stat()
|
|
if err != nil {
|
|
http.ServeFile(w, r, h.staticPath+"/"+h.indexPath)
|
|
return
|
|
}
|
|
|
|
if stat.IsDir() {
|
|
// Serve index.html for directories
|
|
http.ServeFile(w, r, h.staticPath+"/"+h.indexPath)
|
|
return
|
|
}
|
|
|
|
// Otherwise, serve the file
|
|
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
|
|
}
|