Major refactor to library

This commit is contained in:
2025-12-29 09:51:16 +02:00
parent ae169f81e4
commit 767a9e211f
38 changed files with 1073 additions and 492 deletions

47
pkg/handlers/accounts.go Normal file
View File

@@ -0,0 +1,47 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// Accounts returns the list of all configured WhatsApp accounts
func (h *Handlers) Accounts(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(h.config.WhatsApp)
}
// AddAccount adds a new WhatsApp account to the system
func (h *Handlers) AddAccount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var account config.WhatsAppConfig
if err := json.NewDecoder(r.Body).Decode(&account); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Connect to the account
if err := h.whatsappMgr.Connect(context.Background(), account); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Update config
h.config.WhatsApp = append(h.config.WhatsApp, account)
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config", "error", err)
}
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

151
pkg/handlers/businessapi.go Normal file
View File

@@ -0,0 +1,151 @@
package handlers
import (
"net/http"
"strings"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi"
)
// BusinessAPIWebhook handles both verification (GET) and webhook events (POST)
func (h *Handlers) BusinessAPIWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
h.businessAPIWebhookVerify(w, r)
return
}
if r.Method == http.MethodPost {
h.businessAPIWebhookEvent(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// businessAPIWebhookVerify handles webhook verification from Meta
// GET /webhooks/whatsapp/{accountID}?hub.mode=subscribe&hub.verify_token=XXX&hub.challenge=YYY
func (h *Handlers) businessAPIWebhookVerify(w http.ResponseWriter, r *http.Request) {
// Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" {
http.Error(w, "Account ID required in path", http.StatusBadRequest)
return
}
// Get the account configuration
var accountConfig *struct {
ID string
Type string
VerifyToken string
}
for _, cfg := range h.config.WhatsApp {
if cfg.ID == accountID && cfg.Type == "business-api" {
if cfg.BusinessAPI != nil {
accountConfig = &struct {
ID string
Type string
VerifyToken string
}{
ID: cfg.ID,
Type: cfg.Type,
VerifyToken: cfg.BusinessAPI.VerifyToken,
}
break
}
}
}
if accountConfig == nil {
logging.Error("Business API account not found or not configured", "account_id", accountID)
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Get query parameters
mode := r.URL.Query().Get("hub.mode")
token := r.URL.Query().Get("hub.verify_token")
challenge := r.URL.Query().Get("hub.challenge")
logging.Info("Webhook verification request",
"account_id", accountID,
"mode", mode,
"has_challenge", challenge != "")
// Verify the token matches
if mode == "subscribe" && token == accountConfig.VerifyToken {
logging.Info("Webhook verification successful", "account_id", accountID)
w.WriteHeader(http.StatusOK)
w.Write([]byte(challenge))
return
}
logging.Warn("Webhook verification failed",
"account_id", accountID,
"mode", mode,
"token_match", token == accountConfig.VerifyToken)
http.Error(w, "Forbidden", http.StatusForbidden)
}
// businessAPIWebhookEvent handles incoming webhook events from Meta
// POST /webhooks/whatsapp/{accountID}
func (h *Handlers) businessAPIWebhookEvent(w http.ResponseWriter, r *http.Request) {
// Extract account ID from URL path
accountID := extractAccountIDFromPath(r.URL.Path)
if accountID == "" {
http.Error(w, "Account ID required in path", http.StatusBadRequest)
return
}
// Get the client from the manager
client, exists := h.whatsappMgr.GetClient(accountID)
if !exists {
logging.Error("Client not found for webhook", "account_id", accountID)
http.Error(w, "Account not found", http.StatusNotFound)
return
}
// Verify it's a Business API client
if client.GetType() != "business-api" {
logging.Error("Account is not a Business API client", "account_id", accountID, "type", client.GetType())
http.Error(w, "Not a Business API account", http.StatusBadRequest)
return
}
// Cast to Business API client to access HandleWebhook
baClient, ok := client.(*businessapi.Client)
if !ok {
logging.Error("Failed to cast to Business API client", "account_id", accountID)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Process the webhook
if err := baClient.HandleWebhook(r); err != nil {
logging.Error("Failed to process webhook", "account_id", accountID, "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Return 200 OK to acknowledge receipt
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// extractAccountIDFromPath extracts the account ID from the URL path
// Example: /webhooks/whatsapp/business -> "business"
func extractAccountIDFromPath(path string) string {
// Remove trailing slash if present
path = strings.TrimSuffix(path, "/")
// Split by /
parts := strings.Split(path, "/")
// Expected format: /webhooks/whatsapp/{accountID}
if len(parts) >= 4 {
return parts[3]
}
return ""
}

56
pkg/handlers/handlers.go Normal file
View File

@@ -0,0 +1,56 @@
package handlers
import (
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
)
// Handlers holds all HTTP handlers with their dependencies
type Handlers struct {
whatsappMgr *whatsapp.Manager
hookMgr *hooks.Manager
config *config.Config
configPath string
// Auth configuration
authConfig *AuthConfig
}
// AuthConfig configures authentication behavior
type AuthConfig struct {
// Validator is a custom auth validator function
// If nil, uses built-in auth (API key, basic auth)
Validator func(r *http.Request) bool
// Built-in auth settings
APIKey string
Username string
Password string
// Skip auth entirely (not recommended for production)
Disabled bool
}
// New creates a new Handlers instance
func New(mgr *whatsapp.Manager, hookMgr *hooks.Manager, cfg *config.Config, configPath string) *Handlers {
return &Handlers{
whatsappMgr: mgr,
hookMgr: hookMgr,
config: cfg,
configPath: configPath,
authConfig: &AuthConfig{
APIKey: cfg.Server.AuthKey,
Username: cfg.Server.Username,
Password: cfg.Server.Password,
},
}
}
// WithAuthConfig sets custom auth configuration
func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers {
h.authConfig = cfg
return h
}

11
pkg/handlers/health.go Normal file
View File

@@ -0,0 +1,11 @@
package handlers
import (
"encoding/json"
"net/http"
)
// Health handles health check requests
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

74
pkg/handlers/hooks.go Normal file
View File

@@ -0,0 +1,74 @@
package handlers
import (
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/config"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
)
// Hooks returns the list of all configured hooks
func (h *Handlers) Hooks(w http.ResponseWriter, r *http.Request) {
hooks := h.hookMgr.ListHooks()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hooks)
}
// AddHook adds a new hook to the system
func (h *Handlers) AddHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var hook config.Hook
if err := json.NewDecoder(r.Body).Decode(&hook); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
h.hookMgr.AddHook(hook)
// Update config
h.config.Hooks = h.hookMgr.ListHooks()
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config", "error", err)
}
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// RemoveHook removes a hook from the system
func (h *Handlers) RemoveHook(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.hookMgr.RemoveHook(req.ID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Update config
h.config.Hooks = h.hookMgr.ListHooks()
if h.configPath != "" {
if err := config.Save(h.configPath, h.config); err != nil {
logging.Error("Failed to save config", "error", err)
}
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

52
pkg/handlers/media.go Normal file
View File

@@ -0,0 +1,52 @@
package handlers
import (
"net/http"
"path/filepath"
)
// ServeMedia serves media files with path traversal protection
func (h *Handlers) ServeMedia(w http.ResponseWriter, r *http.Request) {
// Expected path format: /api/media/{accountID}/{filename}
path := r.URL.Path[len("/api/media/"):]
// Split path into accountID and filename
var accountID, filename string
for i, ch := range path {
if ch == '/' {
accountID = path[:i]
filename = path[i+1:]
break
}
}
if accountID == "" || filename == "" {
http.Error(w, "Invalid media path", http.StatusBadRequest)
return
}
// Construct full file path
filePath := filepath.Join(h.config.Media.DataPath, accountID, filename)
// Security check: ensure the resolved path is within the media directory
mediaDir := filepath.Join(h.config.Media.DataPath, accountID)
absFilePath, err := filepath.Abs(filePath)
if err != nil {
http.Error(w, "Invalid file path", http.StatusBadRequest)
return
}
absMediaDir, err := filepath.Abs(mediaDir)
if err != nil {
http.Error(w, "Invalid media directory", http.StatusInternalServerError)
return
}
// Check if file path is within media directory (prevent directory traversal)
if len(absFilePath) < len(absMediaDir) || absFilePath[:len(absMediaDir)] != absMediaDir {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Serve the file
http.ServeFile(w, r, absFilePath)
}

View File

@@ -0,0 +1,71 @@
package handlers
import "net/http"
// Auth is the middleware that wraps handlers with authentication
func (h *Handlers) Auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// If auth is disabled
if h.authConfig.Disabled {
next(w, r)
return
}
// If custom validator is provided
if h.authConfig.Validator != nil {
if h.authConfig.Validator(r) {
next(w, r)
return
}
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Built-in auth logic (API key, basic auth)
if h.validateBuiltinAuth(r) {
next(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
}
// validateBuiltinAuth checks API key or basic auth
func (h *Handlers) validateBuiltinAuth(r *http.Request) bool {
// Check if any authentication is configured
hasAuth := h.authConfig.APIKey != "" || h.authConfig.Username != "" || h.authConfig.Password != ""
if !hasAuth {
// No authentication configured, allow access
return true
}
// Check for API key authentication (x-api-key header or Authorization bearer token)
if h.authConfig.APIKey != "" {
// Check x-api-key header
apiKey := r.Header.Get("x-api-key")
if apiKey == h.authConfig.APIKey {
return true
}
// Check Authorization header for bearer token
authHeader := r.Header.Get("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
token := authHeader[7:]
if token == h.authConfig.APIKey {
return true
}
}
}
// Check for username/password authentication (HTTP Basic Auth)
if h.authConfig.Username != "" && h.authConfig.Password != "" {
username, password, ok := r.BasicAuth()
if ok && username == h.authConfig.Username && password == h.authConfig.Password {
return true
}
}
return false
}

189
pkg/handlers/send.go Normal file
View File

@@ -0,0 +1,189 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"net/http"
"git.warky.dev/wdevs/whatshooked/pkg/utils"
"go.mau.fi/whatsmeow/types"
)
// SendMessage sends a text message via WhatsApp
func (h *Handlers) SendMessage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
if err := h.whatsappMgr.SendTextMessage(r.Context(), req.AccountID, jid, req.Text); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// SendImage sends an image via WhatsApp
func (h *Handlers) SendImage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
ImageData string `json:"image_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 image data
imageData, err := base64.StdEncoding.DecodeString(req.ImageData)
if err != nil {
http.Error(w, "Invalid base64 image data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "image/jpeg"
}
if err := h.whatsappMgr.SendImage(r.Context(), req.AccountID, jid, imageData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// SendVideo sends a video via WhatsApp
func (h *Handlers) SendVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
VideoData string `json:"video_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 video data
videoData, err := base64.StdEncoding.DecodeString(req.VideoData)
if err != nil {
http.Error(w, "Invalid base64 video data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default mime type if not provided
if req.MimeType == "" {
req.MimeType = "video/mp4"
}
if err := h.whatsappMgr.SendVideo(r.Context(), req.AccountID, jid, videoData, req.MimeType, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// SendDocument sends a document via WhatsApp
func (h *Handlers) SendDocument(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
AccountID string `json:"account_id"`
To string `json:"to"`
Caption string `json:"caption"`
MimeType string `json:"mime_type"`
Filename string `json:"filename"`
DocumentData string `json:"document_data"` // base64 encoded
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Decode base64 document data
documentData, err := base64.StdEncoding.DecodeString(req.DocumentData)
if err != nil {
http.Error(w, "Invalid base64 document data", http.StatusBadRequest)
return
}
// Format phone number to JID format
formattedJID := utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)
jid, err := types.ParseJID(formattedJID)
if err != nil {
http.Error(w, "Invalid JID", http.StatusBadRequest)
return
}
// Default values if not provided
if req.MimeType == "" {
req.MimeType = "application/octet-stream"
}
if req.Filename == "" {
req.Filename = "document"
}
if err := h.whatsappMgr.SendDocument(r.Context(), req.AccountID, jid, documentData, req.MimeType, req.Filename, req.Caption); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}