From 2b1b77334a3b004c01527c53bcdcc6e3122e288b Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 29 Dec 2025 05:42:57 +0200 Subject: [PATCH] Server refactor completed --- .gitignore | 3 +- TODO.md | 6 +- cmd/server/main.go | 493 ++++------------------------------ cmd/server/middleware.go | 57 ++++ cmd/server/routes.go | 65 +++++ cmd/server/routes_accounts.go | 45 ++++ cmd/server/routes_health.go | 11 + cmd/server/routes_hooks.go | 70 +++++ cmd/server/routes_media.go | 52 ++++ cmd/server/routes_send.go | 189 +++++++++++++ 10 files changed, 551 insertions(+), 440 deletions(-) create mode 100644 cmd/server/middleware.go create mode 100644 cmd/server/routes.go create mode 100644 cmd/server/routes_accounts.go create mode 100644 cmd/server/routes_health.go create mode 100644 cmd/server/routes_hooks.go create mode 100644 cmd/server/routes_media.go create mode 100644 cmd/server/routes_send.go diff --git a/.gitignore b/.gitignore index 7c00427..15d3a08 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,5 @@ sessions/ # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db +/server diff --git a/TODO.md b/TODO.md index 3d66a2b..abffdbd 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,9 @@ # Todo List ## General todo -- [ ] Docker Server Support with docker-compose.yml (Basic Config from .ENV file) -- [ ] Authentication options for cli -- [ ] **Refactor** the code to make it more readable and maintainable. (Split server, hooks and routes. Split CLI into commands etc. Common connection code.) +- [✔️] Docker Server Support with docker-compose.yml (Basic Config from .ENV file) +- [✔️] Authentication options for cli +- [✔️] **Refactor** the code to make it more readable and maintainable. (Split server, hooks and routes. Split CLI into commands etc. Common connection code.) - [ ] Whatsapp Business API support add - [ ] Optional Postgres server connection for Whatsmeo - [ ] Optional Postgres server,database for event saving and hook registration diff --git a/cmd/server/main.go b/cmd/server/main.go index 4403847..9877380 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,8 +2,6 @@ package main import ( "context" - "encoding/base64" - "encoding/json" "flag" "fmt" "net/http" @@ -23,42 +21,84 @@ import ( ) var ( - configPath = flag.String("config", "config.json", "Path to configuration file") + configPath = flag.String("config", "", "Path to configuration file (optional, defaults to user home directory)") ) type Server struct { - config *config.Config - whatsappMgr *whatsapp.Manager - hookMgr *hooks.Manager - httpServer *http.Server - eventBus *events.EventBus + config *config.Config + configPath string + whatsappMgr *whatsapp.Manager + hookMgr *hooks.Manager + httpServer *http.Server + eventBus *events.EventBus +} + +// resolveConfigPath determines the config file path to use +// Priority: 1) provided path (if exists), 2) config.json in current dir, 3) .whatshooked/config.json in user home +func resolveConfigPath(providedPath string) (string, error) { + // If a path was explicitly provided, check if it exists + if providedPath != "" { + if _, err := os.Stat(providedPath); err == nil { + return providedPath, nil + } + // Directory doesn't exist, fall through to default locations + logging.Info("Provided config path directory does not exist, using default locations", "path", providedPath) + } + + // Check for config.json in current directory + currentDirConfig := "config.json" + if _, err := os.Stat(currentDirConfig); err == nil { + return currentDirConfig, nil + } + + // Fall back to user home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + + // Create .whatshooked directory if it doesn't exist + configDir := filepath.Join(homeDir, ".whatshooked") + if err := os.MkdirAll(configDir, 0755); err != nil { + return "", fmt.Errorf("failed to create config directory: %w", err) + } + + return filepath.Join(configDir, "config.json"), nil } func main() { flag.Parse() - // Load configuration - cfg, err := config.Load(*configPath) + // Resolve config path + cfgPath, err := resolveConfigPath(*configPath) if err != nil { - fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) + fmt.Fprintf(os.Stderr, "Failed to resolve config path: %v\n", err) + os.Exit(1) + } + + // Load configuration + cfg, err := config.Load(cfgPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load config from %s: %v\n", cfgPath, err) os.Exit(1) } // Initialize logging logging.Init(cfg.LogLevel) - logging.Info("Starting WhatsHooked server") + logging.Info("Starting WhatsHooked server", "config_path", cfgPath) // Create event bus eventBus := events.NewEventBus() // Create server with config update callback srv := &Server{ - config: cfg, - eventBus: eventBus, - whatsappMgr: whatsapp.NewManager(eventBus, cfg.Media, cfg, *configPath, func(updatedCfg *config.Config) error { - return config.Save(*configPath, updatedCfg) + config: cfg, + configPath: cfgPath, + eventBus: eventBus, + whatsappMgr: whatsapp.NewManager(eventBus, cfg.Media, cfg, cfgPath, func(updatedCfg *config.Config) error { + return config.Save(cfgPath, updatedCfg) }), - hookMgr: hooks.NewManager(eventBus), + hookMgr: hooks.NewManager(eventBus), } // Load hooks @@ -152,422 +192,3 @@ func (s *Server) handleHookResponse(event events.Event) { logging.Info("Message sent from hook response", "account_id", targetAccountID, "to", resp.To) } } - -// authMiddleware validates authentication credentials -func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Check if any authentication is configured - hasAuth := s.config.Server.Username != "" || s.config.Server.Password != "" || s.config.Server.AuthKey != "" - - if !hasAuth { - // No authentication configured, allow access - next(w, r) - return - } - - authenticated := false - - // Check for API key authentication (x-api-key header or Authorization bearer token) - if s.config.Server.AuthKey != "" { - // Check x-api-key header - apiKey := r.Header.Get("x-api-key") - if apiKey == s.config.Server.AuthKey { - authenticated = true - } - - // Check Authorization header for bearer token - if !authenticated { - authHeader := r.Header.Get("Authorization") - if len(authHeader) > 7 && authHeader[:7] == "Bearer " { - token := authHeader[7:] - if token == s.config.Server.AuthKey { - authenticated = true - } - } - } - } - - // Check for username/password authentication (HTTP Basic Auth) - if !authenticated && s.config.Server.Username != "" && s.config.Server.Password != "" { - username, password, ok := r.BasicAuth() - if ok && username == s.config.Server.Username && password == s.config.Server.Password { - authenticated = true - } - } - - if !authenticated { - w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked Server"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - next(w, r) - } -} - -// startHTTPServer starts the HTTP server for CLI communication -func (s *Server) startHTTPServer() { - mux := http.NewServeMux() - - // Health check (no auth required) - mux.HandleFunc("/health", s.handleHealth) - - // Hook management (with auth) - mux.HandleFunc("/api/hooks", s.authMiddleware(s.handleHooks)) - mux.HandleFunc("/api/hooks/add", s.authMiddleware(s.handleAddHook)) - mux.HandleFunc("/api/hooks/remove", s.authMiddleware(s.handleRemoveHook)) - - // Account management (with auth) - mux.HandleFunc("/api/accounts", s.authMiddleware(s.handleAccounts)) - mux.HandleFunc("/api/accounts/add", s.authMiddleware(s.handleAddAccount)) - - // Send messages (with auth) - mux.HandleFunc("/api/send", s.authMiddleware(s.handleSendMessage)) - mux.HandleFunc("/api/send/image", s.authMiddleware(s.handleSendImage)) - mux.HandleFunc("/api/send/video", s.authMiddleware(s.handleSendVideo)) - mux.HandleFunc("/api/send/document", s.authMiddleware(s.handleSendDocument)) - - // Serve media files (with auth) - mux.HandleFunc("/api/media/", s.authMiddleware(s.handleServeMedia)) - - addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port) - s.httpServer = &http.Server{ - Addr: addr, - Handler: mux, - } - - go func() { - logging.Info("Starting HTTP server", - "host", s.config.Server.Host, - "port", s.config.Server.Port, - "address", addr, - ) - logging.Info("HTTP server endpoints available", - "health", "/health", - "hooks", "/api/hooks", - "accounts", "/api/accounts", - "send", "/api/send", - ) - - if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logging.Error("HTTP server error", "error", err) - } - }() -} - -// HTTP Handlers - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) { - hooks := s.hookMgr.ListHooks() - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(hooks) -} - -func (s *Server) handleAddHook(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 - } - - s.hookMgr.AddHook(hook) - - // Update config - s.config.Hooks = s.hookMgr.ListHooks() - if err := config.Save(*configPath, s.config); err != nil { - logging.Error("Failed to save config", "error", err) - } - - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func (s *Server) handleRemoveHook(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 := s.hookMgr.RemoveHook(req.ID); err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - - // Update config - s.config.Hooks = s.hookMgr.ListHooks() - if err := config.Save(*configPath, s.config); err != nil { - logging.Error("Failed to save config", "error", err) - } - - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(s.config.WhatsApp) -} - -func (s *Server) handleAddAccount(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 := s.whatsappMgr.Connect(context.Background(), account); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // Update config - s.config.WhatsApp = append(s.config.WhatsApp, account) - if err := config.Save(*configPath, s.config); err != nil { - logging.Error("Failed to save config", "error", err) - } - - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) -} - -func (s *Server) handleSendMessage(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, s.config.Server.DefaultCountryCode) - - jid, err := types.ParseJID(formattedJID) - if err != nil { - http.Error(w, "Invalid JID", http.StatusBadRequest) - return - } - - if err := s.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"}) -} - -func (s *Server) handleSendImage(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, s.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 := s.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"}) -} - -func (s *Server) handleSendVideo(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, s.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 := s.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"}) -} - -func (s *Server) handleSendDocument(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, s.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 := s.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"}) -} - -func (s *Server) handleServeMedia(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(s.config.Media.DataPath, accountID, filename) - - // Security check: ensure the resolved path is within the media directory - mediaDir := filepath.Join(s.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) -} diff --git a/cmd/server/middleware.go b/cmd/server/middleware.go new file mode 100644 index 0000000..4bbe050 --- /dev/null +++ b/cmd/server/middleware.go @@ -0,0 +1,57 @@ +package main + +import ( + "net/http" +) + +// authMiddleware validates authentication credentials +func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Check if any authentication is configured + hasAuth := s.config.Server.Username != "" || s.config.Server.Password != "" || s.config.Server.AuthKey != "" + + if !hasAuth { + // No authentication configured, allow access + next(w, r) + return + } + + authenticated := false + + // Check for API key authentication (x-api-key header or Authorization bearer token) + if s.config.Server.AuthKey != "" { + // Check x-api-key header + apiKey := r.Header.Get("x-api-key") + if apiKey == s.config.Server.AuthKey { + authenticated = true + } + + // Check Authorization header for bearer token + if !authenticated { + authHeader := r.Header.Get("Authorization") + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + token := authHeader[7:] + if token == s.config.Server.AuthKey { + authenticated = true + } + } + } + } + + // Check for username/password authentication (HTTP Basic Auth) + if !authenticated && s.config.Server.Username != "" && s.config.Server.Password != "" { + username, password, ok := r.BasicAuth() + if ok && username == s.config.Server.Username && password == s.config.Server.Password { + authenticated = true + } + } + + if !authenticated { + w.Header().Set("WWW-Authenticate", `Basic realm="WhatsHooked Server"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next(w, r) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go new file mode 100644 index 0000000..14cdc01 --- /dev/null +++ b/cmd/server/routes.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "net/http" + + "git.warky.dev/wdevs/whatshooked/internal/logging" +) + +// setupRoutes configures all HTTP routes for the server +func (s *Server) setupRoutes() *http.ServeMux { + mux := http.NewServeMux() + + // Health check (no auth required) + mux.HandleFunc("/health", s.handleHealth) + + // Hook management (with auth) + mux.HandleFunc("/api/hooks", s.authMiddleware(s.handleHooks)) + mux.HandleFunc("/api/hooks/add", s.authMiddleware(s.handleAddHook)) + mux.HandleFunc("/api/hooks/remove", s.authMiddleware(s.handleRemoveHook)) + + // Account management (with auth) + mux.HandleFunc("/api/accounts", s.authMiddleware(s.handleAccounts)) + mux.HandleFunc("/api/accounts/add", s.authMiddleware(s.handleAddAccount)) + + // Send messages (with auth) + mux.HandleFunc("/api/send", s.authMiddleware(s.handleSendMessage)) + mux.HandleFunc("/api/send/image", s.authMiddleware(s.handleSendImage)) + mux.HandleFunc("/api/send/video", s.authMiddleware(s.handleSendVideo)) + mux.HandleFunc("/api/send/document", s.authMiddleware(s.handleSendDocument)) + + // Serve media files (with auth) + mux.HandleFunc("/api/media/", s.handleServeMedia) + + return mux +} + +// startHTTPServer starts the HTTP server for CLI communication +func (s *Server) startHTTPServer() { + mux := s.setupRoutes() + + addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port) + s.httpServer = &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + logging.Info("Starting HTTP server", + "host", s.config.Server.Host, + "port", s.config.Server.Port, + "address", addr, + ) + logging.Info("HTTP server endpoints available", + "health", "/health", + "hooks", "/api/hooks", + "accounts", "/api/accounts", + "send", "/api/send", + ) + + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logging.Error("HTTP server error", "error", err) + } + }() +} diff --git a/cmd/server/routes_accounts.go b/cmd/server/routes_accounts.go new file mode 100644 index 0000000..de6e335 --- /dev/null +++ b/cmd/server/routes_accounts.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + + "git.warky.dev/wdevs/whatshooked/internal/config" + "git.warky.dev/wdevs/whatshooked/internal/logging" +) + +// handleAccounts returns the list of all configured WhatsApp accounts +func (s *Server) handleAccounts(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(s.config.WhatsApp) +} + +// handleAddAccount adds a new WhatsApp account to the system +func (s *Server) handleAddAccount(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 := s.whatsappMgr.Connect(context.Background(), account); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Update config + s.config.WhatsApp = append(s.config.WhatsApp, account) + if err := config.Save(s.configPath, s.config); err != nil { + logging.Error("Failed to save config", "error", err) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/cmd/server/routes_health.go b/cmd/server/routes_health.go new file mode 100644 index 0000000..b97b6f5 --- /dev/null +++ b/cmd/server/routes_health.go @@ -0,0 +1,11 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +// handleHealth handles health check requests +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/cmd/server/routes_hooks.go b/cmd/server/routes_hooks.go new file mode 100644 index 0000000..184ad78 --- /dev/null +++ b/cmd/server/routes_hooks.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "net/http" + + "git.warky.dev/wdevs/whatshooked/internal/config" + "git.warky.dev/wdevs/whatshooked/internal/logging" +) + +// handleHooks returns the list of all configured hooks +func (s *Server) handleHooks(w http.ResponseWriter, r *http.Request) { + hooks := s.hookMgr.ListHooks() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(hooks) +} + +// handleAddHook adds a new hook to the system +func (s *Server) handleAddHook(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 + } + + s.hookMgr.AddHook(hook) + + // Update config + s.config.Hooks = s.hookMgr.ListHooks() + if err := config.Save(s.configPath, s.config); err != nil { + logging.Error("Failed to save config", "error", err) + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleRemoveHook removes a hook from the system +func (s *Server) handleRemoveHook(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 := s.hookMgr.RemoveHook(req.ID); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + // Update config + s.config.Hooks = s.hookMgr.ListHooks() + if err := config.Save(s.configPath, s.config); err != nil { + logging.Error("Failed to save config", "error", err) + } + + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/cmd/server/routes_media.go b/cmd/server/routes_media.go new file mode 100644 index 0000000..d6bb597 --- /dev/null +++ b/cmd/server/routes_media.go @@ -0,0 +1,52 @@ +package main + +import ( + "net/http" + "path/filepath" +) + +// handleServeMedia serves media files with path traversal protection +func (s *Server) handleServeMedia(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(s.config.Media.DataPath, accountID, filename) + + // Security check: ensure the resolved path is within the media directory + mediaDir := filepath.Join(s.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) +} diff --git a/cmd/server/routes_send.go b/cmd/server/routes_send.go new file mode 100644 index 0000000..c80cb9b --- /dev/null +++ b/cmd/server/routes_send.go @@ -0,0 +1,189 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "git.warky.dev/wdevs/whatshooked/internal/utils" + "go.mau.fi/whatsmeow/types" +) + +// handleSendMessage sends a text message via WhatsApp +func (s *Server) handleSendMessage(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, s.config.Server.DefaultCountryCode) + + jid, err := types.ParseJID(formattedJID) + if err != nil { + http.Error(w, "Invalid JID", http.StatusBadRequest) + return + } + + if err := s.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"}) +} + +// handleSendImage sends an image via WhatsApp +func (s *Server) handleSendImage(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, s.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 := s.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"}) +} + +// handleSendVideo sends a video via WhatsApp +func (s *Server) handleSendVideo(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, s.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 := s.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"}) +} + +// handleSendDocument sends a document via WhatsApp +func (s *Server) handleSendDocument(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, s.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 := s.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"}) +}