refactor(API): Relspect integration
Some checks failed
CI / Test (1.23) (push) Failing after -22m46s
CI / Test (1.22) (push) Failing after -22m32s
CI / Build (push) Failing after -23m30s
CI / Lint (push) Failing after -23m12s

This commit is contained in:
Hein
2026-02-05 13:39:43 +02:00
parent 71f26c214f
commit f9773bd07f
33 changed files with 7512 additions and 58 deletions

271
pkg/api/security.go Normal file
View File

@@ -0,0 +1,271 @@
package api
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/storage"
"github.com/bitechdev/ResolveSpec/pkg/security"
"github.com/golang-jwt/jwt/v5"
"github.com/uptrace/bun"
"golang.org/x/crypto/bcrypt"
)
// SecurityProvider implements ResolveSpec SecurityProvider interface
type SecurityProvider struct {
jwtSecret []byte
userRepo *storage.UserRepository
sessionRepo *storage.SessionRepository
config *config.Config // Add config for Phase 1 auth
}
// NewSecurityProvider creates a new security provider
func NewSecurityProvider(jwtSecret string, db *bun.DB, cfg *config.Config) security.SecurityProvider {
return &SecurityProvider{
jwtSecret: []byte(jwtSecret),
userRepo: storage.NewUserRepository(db),
sessionRepo: storage.NewSessionRepository(db),
config: cfg,
}
}
// Claims represents JWT claims
type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// GenerateToken generates a JWT token
func (sp *SecurityProvider) GenerateToken(userID int, username, role string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: userID,
Username: username,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "whatshooked",
Subject: fmt.Sprintf("%d", userID),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(sp.jwtSecret)
}
// ValidateToken validates a JWT token and returns the claims
func (sp *SecurityProvider) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return sp.jwtSecret, nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, fmt.Errorf("invalid token")
}
// Login authenticates a user (implements security.Authenticator)
func (sp *SecurityProvider) Login(ctx context.Context, req security.LoginRequest) (*security.LoginResponse, error) {
// Get user by username
user, err := sp.userRepo.GetByUsername(ctx, req.Username)
if err != nil {
return nil, fmt.Errorf("invalid credentials")
}
// Check if user is active
if !user.Active {
return nil, fmt.Errorf("user is inactive")
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(user.Password.String()), []byte(req.Password)); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
// Generate JWT token
token, err := sp.GenerateToken(int(user.ID.Int64()), req.Username, user.Role.String())
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
// Build user context
userCtx := &security.UserContext{
UserID: int(user.ID.Int64()),
UserName: req.Username,
Email: user.Email.String(),
Roles: []string{user.Role.String()},
Claims: map[string]any{
"role": user.Role.String(),
},
}
return &security.LoginResponse{
Token: token,
User: userCtx,
ExpiresIn: int64(24 * time.Hour.Seconds()),
}, nil
}
// Logout logs out a user (implements security.Authenticator)
func (sp *SecurityProvider) Logout(ctx context.Context, req security.LogoutRequest) error {
// For JWT, we can implement token blacklisting if needed
// For now, just return success (JWT will expire naturally)
return nil
}
// Authenticate authenticates an HTTP request (implements security.Authenticator)
func (sp *SecurityProvider) Authenticate(r *http.Request) (*security.UserContext, error) {
// Try JWT authentication first
token := extractBearerToken(r)
if token != "" {
claims, err := sp.ValidateToken(token)
if err == nil {
// Get user from database
user, err := sp.userRepo.GetByID(r.Context(), fmt.Sprintf("%d", claims.UserID))
if err == nil && user.Active {
return &security.UserContext{
UserID: claims.UserID,
UserName: claims.Username,
Email: user.Email.String(),
Roles: []string{user.Role.String()},
Claims: map[string]any{
"role": user.Role.String(),
},
}, nil
}
}
}
// Try Phase 1 authentication (API key, basic auth)
if sp.validatePhase1Auth(r) {
// Create a generic user context for Phase 1 auth
// Use username from config or "api-user" if using API key
username := "api-user"
if sp.config.Server.Username != "" {
username = sp.config.Server.Username
}
// Check if using basic auth to get actual username
if basicUser, _, ok := r.BasicAuth(); ok && basicUser != "" {
username = basicUser
}
return &security.UserContext{
UserID: 0, // No user ID for Phase 1 auth
UserName: username,
Email: "",
Roles: []string{"admin"}, // Phase 1 auth gets admin role
Claims: map[string]any{
"role": "admin",
"legacy": true, // Mark as legacy auth
},
}, nil
}
return nil, fmt.Errorf("authentication failed")
}
// validatePhase1Auth checks Phase 1 authentication (API key or basic auth)
func (sp *SecurityProvider) validatePhase1Auth(r *http.Request) bool {
// Check if any Phase 1 authentication is configured
hasAuth := sp.config.Server.AuthKey != "" ||
sp.config.Server.Username != "" ||
sp.config.Server.Password != ""
if !hasAuth {
// No Phase 1 authentication configured
return false
}
// Check for API key authentication (x-api-key header)
if sp.config.Server.AuthKey != "" {
apiKey := r.Header.Get("x-api-key")
if apiKey == sp.config.Server.AuthKey {
return true
}
// Also check Authorization header for bearer token (API key)
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
if token == sp.config.Server.AuthKey {
return true
}
}
}
// Check for username/password authentication (HTTP Basic Auth)
if sp.config.Server.Username != "" && sp.config.Server.Password != "" {
username, password, ok := r.BasicAuth()
if ok && username == sp.config.Server.Username && password == sp.config.Server.Password {
return true
}
}
return false
}
// GetColumnSecurity returns column security rules (implements security.ColumnSecurityProvider)
func (sp *SecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
// Return empty - no column-level security for now
return []security.ColumnSecurity{}, nil
}
// GetRowSecurity returns row security rules (implements security.RowSecurityProvider)
func (sp *SecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (security.RowSecurity, error) {
// Get user to check role
user, err := sp.userRepo.GetByID(ctx, fmt.Sprintf("%d", userID))
if err != nil {
return security.RowSecurity{}, err
}
// Admin can access all rows
if user.Role.String() == "admin" {
return security.RowSecurity{
Template: "", // Empty template means no filtering
}, nil
}
// Regular users can only access their own data
// Apply user_id filter for tables that have user_id column
userTables := []string{"api_keys", "hooks", "whatsapp_accounts"}
for _, userTable := range userTables {
if table == userTable {
return security.RowSecurity{
Template: fmt.Sprintf("user_id = %d", userID),
}, nil
}
}
// For other tables, no additional filtering
return security.RowSecurity{
Template: "",
}, nil
}
// extractBearerToken extracts Bearer token from Authorization header
func extractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if len(auth) > 7 && strings.HasPrefix(auth, "Bearer ") {
return auth[7:]
}
return ""
}

343
pkg/api/server.go Normal file
View File

@@ -0,0 +1,343 @@
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)
}