initial commit
This commit is contained in:
56
cmd/cli/config.go
Normal file
56
cmd/cli/config.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// CLIConfig holds the CLI configuration
|
||||
type CLIConfig struct {
|
||||
ServerURL string
|
||||
}
|
||||
|
||||
// LoadCLIConfig loads configuration with priority: config file → ENV → flag
|
||||
func LoadCLIConfig(configFile string, serverFlag string) (*CLIConfig, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Set defaults
|
||||
v.SetDefault("server_url", "http://localhost:8080")
|
||||
|
||||
// 1. Load from config file (lowest priority)
|
||||
if configFile != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
} else {
|
||||
// Look for config in home directory
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
v.AddConfigPath(filepath.Join(home, ".whatshooked"))
|
||||
v.SetConfigName("cli")
|
||||
v.SetConfigType("json")
|
||||
}
|
||||
// Also look in current directory
|
||||
v.AddConfigPath(".")
|
||||
v.SetConfigName(".whatshooked-cli")
|
||||
v.SetConfigType("json")
|
||||
}
|
||||
|
||||
// Read config file if it exists (don't error if it doesn't)
|
||||
_ = v.ReadInConfig()
|
||||
|
||||
// 2. Override with environment variables (medium priority)
|
||||
v.SetEnvPrefix("WHATSHOOKED")
|
||||
v.AutomaticEnv()
|
||||
|
||||
// 3. Override with command-line flag (highest priority)
|
||||
if serverFlag != "" {
|
||||
v.Set("server_url", serverFlag)
|
||||
}
|
||||
|
||||
cfg := &CLIConfig{
|
||||
ServerURL: v.GetString("server_url"),
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
629
cmd/cli/main.go
Normal file
629
cmd/cli/main.go
Normal file
@@ -0,0 +1,629 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
cfgFile string
|
||||
serverURL string
|
||||
cliConfig *CLIConfig
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "whatshook-cli",
|
||||
Short: "WhatsHooked CLI - Manage WhatsApp webhooks",
|
||||
Long: `A command-line interface for managing WhatsHooked server, hooks, and WhatsApp accounts.`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
var err error
|
||||
cliConfig, err = LoadCLIConfig(cfgFile, serverURL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: $HOME/.whatshooked/cli.json)")
|
||||
rootCmd.PersistentFlags().StringVar(&serverURL, "server", "", "server URL (default: http://localhost:8080)")
|
||||
|
||||
rootCmd.AddCommand(healthCmd)
|
||||
rootCmd.AddCommand(hooksCmd)
|
||||
rootCmd.AddCommand(accountsCmd)
|
||||
rootCmd.AddCommand(sendCmd)
|
||||
}
|
||||
|
||||
// Health command
|
||||
var healthCmd = &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Check server health",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
checkHealth(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
// Hooks command group
|
||||
var hooksCmd = &cobra.Command{
|
||||
Use: "hooks",
|
||||
Short: "Manage webhooks",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
listHooks(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
var hooksListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all hooks",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
listHooks(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
var hooksAddCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new hook",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
addHook(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
var hooksRemoveCmd = &cobra.Command{
|
||||
Use: "remove <hook_id>",
|
||||
Short: "Remove a hook",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
removeHook(cliConfig.ServerURL, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
hooksCmd.AddCommand(hooksListCmd)
|
||||
hooksCmd.AddCommand(hooksAddCmd)
|
||||
hooksCmd.AddCommand(hooksRemoveCmd)
|
||||
}
|
||||
|
||||
// Accounts command group
|
||||
var accountsCmd = &cobra.Command{
|
||||
Use: "accounts",
|
||||
Short: "Manage WhatsApp accounts",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
listAccounts(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
var accountsListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all accounts",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
listAccounts(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
var accountsAddCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new WhatsApp account",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
addAccount(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
accountsCmd.AddCommand(accountsListCmd)
|
||||
accountsCmd.AddCommand(accountsAddCmd)
|
||||
}
|
||||
|
||||
// Send command group
|
||||
var sendCmd = &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "Send messages",
|
||||
}
|
||||
|
||||
var sendTextCmd = &cobra.Command{
|
||||
Use: "text",
|
||||
Short: "Send a text message",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
sendMessage(cliConfig.ServerURL)
|
||||
},
|
||||
}
|
||||
|
||||
var sendImageCmd = &cobra.Command{
|
||||
Use: "image <file_path>",
|
||||
Short: "Send an image",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
sendImage(cliConfig.ServerURL, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var sendVideoCmd = &cobra.Command{
|
||||
Use: "video <file_path>",
|
||||
Short: "Send a video",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
sendVideo(cliConfig.ServerURL, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
var sendDocumentCmd = &cobra.Command{
|
||||
Use: "document <file_path>",
|
||||
Short: "Send a document",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
sendDocument(cliConfig.ServerURL, args[0])
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
sendCmd.AddCommand(sendTextCmd)
|
||||
sendCmd.AddCommand(sendImageCmd)
|
||||
sendCmd.AddCommand(sendVideoCmd)
|
||||
sendCmd.AddCommand(sendDocumentCmd)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func checkHealth(serverURL string) {
|
||||
resp, err := http.Get(serverURL + "/health")
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
fmt.Printf("Error decoding response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Server status: %s\n", result["status"])
|
||||
}
|
||||
|
||||
func listHooks(serverURL string) {
|
||||
resp, err := http.Get(serverURL + "/api/hooks")
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var hooks []config.Hook
|
||||
if err := json.NewDecoder(resp.Body).Decode(&hooks); err != nil {
|
||||
fmt.Printf("Error decoding response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(hooks) == 0 {
|
||||
fmt.Println("No hooks configured")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Configured hooks (%d):\n\n", len(hooks))
|
||||
for _, hook := range hooks {
|
||||
status := "inactive"
|
||||
if hook.Active {
|
||||
status = "active"
|
||||
}
|
||||
fmt.Printf("ID: %s\n", hook.ID)
|
||||
fmt.Printf("Name: %s\n", hook.Name)
|
||||
fmt.Printf("URL: %s\n", hook.URL)
|
||||
fmt.Printf("Method: %s\n", hook.Method)
|
||||
fmt.Printf("Status: %s\n", status)
|
||||
if hook.Description != "" {
|
||||
fmt.Printf("Description: %s\n", hook.Description)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func addHook(serverURL string) {
|
||||
var hook config.Hook
|
||||
|
||||
fmt.Print("Hook ID: ")
|
||||
fmt.Scanln(&hook.ID)
|
||||
|
||||
fmt.Print("Hook Name: ")
|
||||
fmt.Scanln(&hook.Name)
|
||||
|
||||
fmt.Print("Webhook URL: ")
|
||||
fmt.Scanln(&hook.URL)
|
||||
|
||||
fmt.Print("HTTP Method (POST): ")
|
||||
fmt.Scanln(&hook.Method)
|
||||
if hook.Method == "" {
|
||||
hook.Method = "POST"
|
||||
}
|
||||
|
||||
fmt.Print("Description (optional): ")
|
||||
fmt.Scanln(&hook.Description)
|
||||
|
||||
hook.Active = true
|
||||
|
||||
data, err := json.Marshal(hook)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+"/api/hooks/add", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Error: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Hook added successfully")
|
||||
}
|
||||
|
||||
func removeHook(serverURL string, id string) {
|
||||
req := map[string]string{"id": id}
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+"/api/hooks/remove", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Error: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Hook removed successfully")
|
||||
}
|
||||
|
||||
func listAccounts(serverURL string) {
|
||||
resp, err := http.Get(serverURL + "/api/accounts")
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var accounts []config.WhatsAppConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&accounts); err != nil {
|
||||
fmt.Printf("Error decoding response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(accounts) == 0 {
|
||||
fmt.Println("No accounts configured")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Configured accounts (%d):\n\n", len(accounts))
|
||||
for _, acc := range accounts {
|
||||
fmt.Printf("ID: %s\n", acc.ID)
|
||||
fmt.Printf("Phone Number: %s\n", acc.PhoneNumber)
|
||||
fmt.Printf("Session Path: %s\n", acc.SessionPath)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func addAccount(serverURL string) {
|
||||
var account config.WhatsAppConfig
|
||||
|
||||
fmt.Print("Account ID: ")
|
||||
fmt.Scanln(&account.ID)
|
||||
|
||||
fmt.Print("Phone Number (with country code): ")
|
||||
fmt.Scanln(&account.PhoneNumber)
|
||||
|
||||
fmt.Print("Session Path: ")
|
||||
fmt.Scanln(&account.SessionPath)
|
||||
|
||||
data, err := json.Marshal(account)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+"/api/accounts/add", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Error: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Account added successfully")
|
||||
fmt.Println("Check server logs for QR code to pair the device")
|
||||
}
|
||||
|
||||
func sendMessage(serverURL string) {
|
||||
var req struct {
|
||||
AccountID string `json:"account_id"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
fmt.Print("Account ID: ")
|
||||
fmt.Scanln(&req.AccountID)
|
||||
|
||||
fmt.Print("Recipient (phone number or JID, e.g., 0834606792 or 1234567890@s.whatsapp.net): ")
|
||||
fmt.Scanln(&req.To)
|
||||
|
||||
fmt.Print("Message text: ")
|
||||
reader := os.Stdin
|
||||
buf := make([]byte, 1024)
|
||||
n, err := reader.Read(buf)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
req.Text = string(buf[:n])
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+"/api/send", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Error: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Message sent successfully")
|
||||
}
|
||||
|
||||
func sendImage(serverURL string, filePath string) {
|
||||
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"`
|
||||
}
|
||||
|
||||
fmt.Print("Account ID: ")
|
||||
fmt.Scanln(&req.AccountID)
|
||||
|
||||
fmt.Print("Recipient (phone number): ")
|
||||
fmt.Scanln(&req.To)
|
||||
|
||||
fmt.Print("Caption (optional): ")
|
||||
reader := os.Stdin
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := reader.Read(buf)
|
||||
req.Caption = strings.TrimSpace(string(buf[:n]))
|
||||
|
||||
// Read image file
|
||||
imageData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading image file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
req.ImageData = base64.StdEncoding.EncodeToString(imageData)
|
||||
|
||||
// Detect mime type from extension
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
req.MimeType = "image/jpeg"
|
||||
case ".png":
|
||||
req.MimeType = "image/png"
|
||||
case ".gif":
|
||||
req.MimeType = "image/gif"
|
||||
case ".webp":
|
||||
req.MimeType = "image/webp"
|
||||
default:
|
||||
req.MimeType = "image/jpeg"
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+"/api/send/image", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Error: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Image sent successfully")
|
||||
}
|
||||
|
||||
func sendVideo(serverURL string, filePath string) {
|
||||
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"`
|
||||
}
|
||||
|
||||
fmt.Print("Account ID: ")
|
||||
fmt.Scanln(&req.AccountID)
|
||||
|
||||
fmt.Print("Recipient (phone number): ")
|
||||
fmt.Scanln(&req.To)
|
||||
|
||||
fmt.Print("Caption (optional): ")
|
||||
reader := os.Stdin
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := reader.Read(buf)
|
||||
req.Caption = strings.TrimSpace(string(buf[:n]))
|
||||
|
||||
// Read video file
|
||||
videoData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading video file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
req.VideoData = base64.StdEncoding.EncodeToString(videoData)
|
||||
|
||||
// Detect mime type from extension
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
req.MimeType = "video/mp4"
|
||||
case ".mov":
|
||||
req.MimeType = "video/quicktime"
|
||||
case ".avi":
|
||||
req.MimeType = "video/x-msvideo"
|
||||
case ".webm":
|
||||
req.MimeType = "video/webm"
|
||||
case ".3gp":
|
||||
req.MimeType = "video/3gpp"
|
||||
default:
|
||||
req.MimeType = "video/mp4"
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+"/api/send/video", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Error: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Video sent successfully")
|
||||
}
|
||||
|
||||
func sendDocument(serverURL string, filePath string) {
|
||||
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"`
|
||||
}
|
||||
|
||||
fmt.Print("Account ID: ")
|
||||
fmt.Scanln(&req.AccountID)
|
||||
|
||||
fmt.Print("Recipient (phone number): ")
|
||||
fmt.Scanln(&req.To)
|
||||
|
||||
fmt.Print("Caption (optional): ")
|
||||
reader := os.Stdin
|
||||
buf := make([]byte, 1024)
|
||||
n, _ := reader.Read(buf)
|
||||
req.Caption = strings.TrimSpace(string(buf[:n]))
|
||||
|
||||
// Read document file
|
||||
documentData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading document file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
req.DocumentData = base64.StdEncoding.EncodeToString(documentData)
|
||||
|
||||
// Use the original filename
|
||||
req.Filename = filepath.Base(filePath)
|
||||
|
||||
// Detect mime type from extension
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
req.MimeType = "application/pdf"
|
||||
case ".doc":
|
||||
req.MimeType = "application/msword"
|
||||
case ".docx":
|
||||
req.MimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".xls":
|
||||
req.MimeType = "application/vnd.ms-excel"
|
||||
case ".xlsx":
|
||||
req.MimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case ".txt":
|
||||
req.MimeType = "text/plain"
|
||||
case ".zip":
|
||||
req.MimeType = "application/zip"
|
||||
default:
|
||||
req.MimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := http.Post(serverURL+"/api/send/document", "application/json", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("Error: %s\n", string(body))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Document sent successfully")
|
||||
}
|
||||
573
cmd/server/main.go
Normal file
573
cmd/server/main.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.warky.dev/wdevs/whatshooked/internal/config"
|
||||
"git.warky.dev/wdevs/whatshooked/internal/events"
|
||||
"git.warky.dev/wdevs/whatshooked/internal/hooks"
|
||||
"git.warky.dev/wdevs/whatshooked/internal/logging"
|
||||
"git.warky.dev/wdevs/whatshooked/internal/utils"
|
||||
"git.warky.dev/wdevs/whatshooked/internal/whatsapp"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
var (
|
||||
configPath = flag.String("config", "config.json", "Path to configuration file")
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
whatsappMgr *whatsapp.Manager
|
||||
hookMgr *hooks.Manager
|
||||
httpServer *http.Server
|
||||
eventBus *events.EventBus
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize logging
|
||||
logging.Init(cfg.LogLevel)
|
||||
logging.Info("Starting WhatsHooked server")
|
||||
|
||||
// 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)
|
||||
}),
|
||||
hookMgr: hooks.NewManager(eventBus),
|
||||
}
|
||||
|
||||
// Load hooks
|
||||
srv.hookMgr.LoadHooks(cfg.Hooks)
|
||||
|
||||
// Start hook manager to listen for events
|
||||
srv.hookMgr.Start()
|
||||
|
||||
// Subscribe to hook success events to handle webhook responses
|
||||
srv.eventBus.Subscribe(events.EventHookSuccess, srv.handleHookResponse)
|
||||
|
||||
// Start HTTP server for CLI BEFORE connecting to WhatsApp
|
||||
// This ensures all infrastructure is ready before events start flowing
|
||||
srv.startHTTPServer()
|
||||
|
||||
// Give HTTP server a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
logging.Info("HTTP server ready, connecting to WhatsApp accounts")
|
||||
|
||||
// Connect to WhatsApp accounts
|
||||
ctx := context.Background()
|
||||
for _, waCfg := range cfg.WhatsApp {
|
||||
if err := srv.whatsappMgr.Connect(ctx, waCfg); err != nil {
|
||||
logging.Error("Failed to connect to WhatsApp", "account_id", waCfg.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for interrupt signal
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
|
||||
logging.Info("Shutting down server")
|
||||
|
||||
// Graceful shutdown
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if srv.httpServer != nil {
|
||||
srv.httpServer.Shutdown(shutdownCtx)
|
||||
}
|
||||
|
||||
srv.whatsappMgr.DisconnectAll()
|
||||
logging.Info("Server stopped")
|
||||
}
|
||||
|
||||
// handleHookResponse processes hook success events to handle two-way communication
|
||||
func (s *Server) handleHookResponse(event events.Event) {
|
||||
// Use event context for sending message
|
||||
ctx := event.Context
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Extract response from event data
|
||||
responseData, ok := event.Data["response"]
|
||||
if !ok || responseData == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to cast to HookResponse
|
||||
resp, ok := responseData.(hooks.HookResponse)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.SendMessage {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine which account to use - default to first available if not specified
|
||||
targetAccountID := resp.AccountID
|
||||
if targetAccountID == "" && len(s.config.WhatsApp) > 0 {
|
||||
targetAccountID = s.config.WhatsApp[0].ID
|
||||
}
|
||||
|
||||
// Format phone number to JID format
|
||||
formattedJID := utils.FormatPhoneToJID(resp.To, s.config.Server.DefaultCountryCode)
|
||||
|
||||
// Parse JID
|
||||
jid, err := types.ParseJID(formattedJID)
|
||||
if err != nil {
|
||||
logging.Error("Invalid JID in hook response", "jid", formattedJID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send message with context
|
||||
if err := s.whatsappMgr.SendTextMessage(ctx, targetAccountID, jid, resp.Text); err != nil {
|
||||
logging.Error("Failed to send message from hook response", "error", err)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user