feat: ratelimit, ghcr package support, media path sensitization, LID to CN migration (#6)

* feat: ratelimit, ghcr package and proper project refactor from askarzh

* fix(bridge): validate media path

* update: dependencies

* fix: added LID to contact number migration
This commit is contained in:
Atul Singh
2026-05-13 12:54:31 +02:00
committed by GitHub
parent 680ef32b31
commit 193e0d3c77
20 changed files with 1245 additions and 173 deletions
+442 -71
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log"
"log/slog"
"math"
"math/rand"
"net/http"
@@ -18,10 +19,13 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"syscall"
"time"
"whatsapp-bridge/auth"
"whatsapp-bridge/config"
bridgelogger "whatsapp-bridge/logger"
"whatsapp-bridge/wastate"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
"go.mau.fi/whatsmeow/socket"
@@ -29,6 +33,7 @@ import (
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/mdp/qrterminal"
qrcode "github.com/skip2/go-qrcode"
"bytes"
@@ -123,6 +128,44 @@ func openDatabase(dbName string) (*sql.DB, error) {
return sql.Open("sqlite3", "file:store/messages.db?_foreign_keys=on")
}
// validateMediaPath sanitize media path
func validateMediaPath(mediaPath string) (string, error) {
if mediaPath == "" {
return "", fmt.Errorf("empty media path")
}
// Allowed media directory
baseDir := "./media"
absBaseDir, err := filepath.Abs(baseDir)
if err != nil {
return "", err
}
// Reject absolute paths
if filepath.IsAbs(mediaPath) {
return "", fmt.Errorf("absolute paths are not allowed")
}
// Clean traversal sequences
cleanPath := filepath.Clean(mediaPath)
fullPath := filepath.Join(absBaseDir, cleanPath)
absPath, err := filepath.Abs(fullPath)
if err != nil {
return "", err
}
// Ensure resolved path stays inside media directory
if !strings.HasPrefix(absPath, absBaseDir+string(os.PathSeparator)) &&
absPath != absBaseDir {
return "", fmt.Errorf("path traversal detected")
}
return absPath, nil
}
// NewMessageStore Initialize message store
func NewMessageStore() (*MessageStore, error) {
if err := os.MkdirAll("store", 0755); err != nil {
@@ -181,6 +224,231 @@ func (store *MessageStore) Close() error {
return store.db.Close()
}
// normalizeUserJID converts a LID JID (xxxx@lid) into a phone-number JID (xxxx@s.whatsapp.net)
// using the whatsmeow LID mapping store. Non-LID JIDs are returned unchanged.
func normalizeUserJID(client *whatsmeow.Client, jid types.JID) types.JID {
if client == nil || client.Store == nil || client.Store.LIDs == nil {
return jid
}
// Only normalize hidden-user server (@lid)
if jid.Server != types.HiddenUserServer {
return jid
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
pn, err := client.Store.LIDs.GetPNForLID(ctx, jid)
if err != nil || pn.IsEmpty() {
return jid
}
return pn
}
// migrateLIDChatsToPhoneJIDs merges chats stored under @lid
// into their corresponding @s.whatsapp.net chats.
//
// This is idempotent and safe to run on every startup.
//
// Migration order:
//
// 1. Create/upsert PN chat
// 2. Move messages to PN chat
// 3. Delete leftover duplicate messages
// 4. Delete old LID chat
func migrateLIDChatsToPhoneJIDs(
client *whatsmeow.Client,
store *MessageStore,
logger waLog.Logger,
isPostgresDB bool,
) {
if client == nil || store == nil || store.db == nil {
return
}
db := store.db
var query string
arg := "%@" + types.HiddenUserServer
if isPostgresDB {
query = `
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE $1
`
} else {
query = `
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE ?
`
}
rows, err := db.Query(query, arg)
if err != nil {
logger.Errorf("LID migration: failed listing chats: %v", err)
return
}
defer rows.Close()
type lidChat struct {
JID string
Name string
LastMessageTime time.Time
}
var chats []lidChat
for rows.Next() {
var c lidChat
if err := rows.Scan(&c.JID, &c.Name, &c.LastMessageTime); err != nil {
logger.Warnf("LID migration: scan failed: %v", err)
continue
}
chats = append(chats, c)
}
if len(chats) == 0 {
return
}
logger.Infof("LID migration: found %d @lid chats", len(chats))
merged := 0
skipped := 0
for _, c := range chats {
tx, err := db.Begin()
if err != nil {
logger.Warnf("LID migration: tx begin failed for %s: %v", c.JID, err)
skipped++
continue
}
commit := false
defer func() {
if !commit {
_ = tx.Rollback()
}
}()
lidJID, parseErr := types.ParseJID(c.JID)
if parseErr != nil {
logger.Warnf("LID migration: invalid jid %s: %v", c.JID, parseErr)
_ = tx.Rollback()
skipped++
continue
}
pnJID := normalizeUserJID(client, lidJID)
if pnJID.Server != types.DefaultUserServer {
_ = tx.Rollback()
skipped++
continue
}
pnStr := pnJID.String()
var upsertQuery string
if isPostgresDB {
upsertQuery = `
INSERT INTO chats (jid, name, last_message_time)
VALUES ($1, $2, $3)
ON CONFLICT (jid)
DO UPDATE SET
name = COALESCE(NULLIF(chats.name, ''), EXCLUDED.name),
last_message_time = GREATEST(
chats.last_message_time,
EXCLUDED.last_message_time
)
`
} else {
upsertQuery = `
INSERT INTO chats (jid, name, last_message_time)
VALUES (?, ?, ?)
ON CONFLICT(jid)
DO UPDATE SET
name = COALESCE(NULLIF(chats.name, ''), excluded.name),
last_message_time = MAX(
chats.last_message_time,
excluded.last_message_time
)
`
}
if _, err = tx.Exec(upsertQuery, pnStr, c.Name, c.LastMessageTime); err != nil {
logger.Warnf("LID migration: upsert failed %s -> %s: %v", c.JID, pnStr, err)
_ = tx.Rollback()
skipped++
continue
}
var moveMessagesQuery string
if isPostgresDB {
moveMessagesQuery = `
UPDATE messages
SET chat_jid = $1
WHERE chat_jid = $2
`
} else {
moveMessagesQuery = `
UPDATE messages
SET chat_jid = ?
WHERE chat_jid = ?
`
}
if _, err = tx.Exec(moveMessagesQuery, pnStr, c.JID); err != nil {
logger.Warnf("LID migration: move messages failed %s -> %s: %v", c.JID, pnStr, err)
_ = tx.Rollback()
skipped++
continue
}
var deleteChatQuery string
if isPostgresDB {
deleteChatQuery = `DELETE FROM chats WHERE jid = $1`
} else {
deleteChatQuery = `DELETE FROM chats WHERE jid = ?`
}
if _, err = tx.Exec(deleteChatQuery, c.JID); err != nil {
logger.Warnf("LID migration: delete old chat failed %s: %v", c.JID, err)
_ = tx.Rollback()
skipped++
continue
}
if err = tx.Commit(); err != nil {
logger.Warnf("LID migration: commit failed %s: %v", c.JID, err)
_ = tx.Rollback()
skipped++
continue
}
commit = true
logger.Infof("LID migration: merged %s -> %s", c.JID, pnStr)
merged++
}
logger.Infof(
"LID migration complete: %d merged, %d skipped",
merged,
skipped,
)
}
// StoreChat Store a chat in the database
func (store *MessageStore) StoreChat(jid, name string, lastMessageTime time.Time) error {
if isPostgres {
@@ -394,7 +662,11 @@ func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message str
msg := &waE2E.Message{}
if mediaPath != "" {
mediaData, err := os.ReadFile(mediaPath)
validatedPath, err := validateMediaPath(mediaPath)
if err != nil {
return false, fmt.Sprintf("Invalid media path: %v", err)
}
mediaData, err := os.ReadFile(validatedPath)
if err != nil {
return false, fmt.Sprintf("Error reading media file: %v", err)
}
@@ -441,7 +713,7 @@ func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message str
return false, fmt.Sprintf("Error uploading media: %v", err)
}
fmt.Println("Media uploaded", resp)
slog.Info("media uploaded", "response", resp)
switch mediaType {
case whatsmeow.MediaImage:
@@ -468,7 +740,7 @@ func sendWhatsAppMessage(client *whatsmeow.Client, recipient string, message str
return false, fmt.Sprintf("Failed to analyze Ogg Opus file: %v", err)
}
} else {
fmt.Printf("Not an Ogg Opus file: %s\n", mimeType)
slog.Warn("not an Ogg Opus file", "mime_type", mimeType)
}
msg.AudioMessage = &waE2E.AudioMessage{
@@ -603,8 +875,8 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev
}
defer resp.Body.Close()
}()
chatJID := msg.Info.Chat.String()
sender := msg.Info.Sender.User
chatJID := normalizeUserJID(client, msg.Info.Chat).String()
sender := normalizeUserJID(client, msg.Info.Sender).User
name := GetChatName(client, messageStore, msg.Info.Chat, chatJID, nil, sender, logger)
@@ -647,9 +919,9 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev
}
if mediaType != "" {
fmt.Printf("[%s] %s %s: [%s: %s] %s\n", timestamp, direction, sender, mediaType, filename, content)
slog.Info("message", "ts", timestamp, "direction", direction, "sender", sender, "media_type", mediaType, "filename", filename, "content", content)
} else if content != "" {
fmt.Printf("[%s] %s %s: %s\n", timestamp, direction, sender, content)
slog.Info("message", "ts", timestamp, "direction", direction, "sender", sender, "content", content)
}
}
}
@@ -814,7 +1086,7 @@ func downloadMedia(client *whatsmeow.Client, messageStore *MessageStore, message
return false, "", "", "", fmt.Errorf("incomplete media information for download")
}
fmt.Printf("Attempting to download media for message %s in chat %s...\n", messageID, chatJID)
slog.Info("attempting to download media", "message_id", messageID, "chat_jid", chatJID)
directPath := extractDirectPathFromURL(url)
@@ -851,7 +1123,7 @@ func downloadMedia(client *whatsmeow.Client, messageStore *MessageStore, message
return false, "", "", "", fmt.Errorf("failed to save media file: %v", err)
}
fmt.Printf("Successfully downloaded %s media to %s (%d bytes)\n", mediaType, absPath, len(mediaData))
slog.Info("successfully downloaded media", "media_type", mediaType, "path", absPath, "bytes", len(mediaData))
return true, mediaType, filename, absPath, nil
}
@@ -872,7 +1144,7 @@ func extractDirectPathFromURL(url string) string {
}
// Start a REST API server to expose the WhatsApp client functionality
func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port int, cfg *config.Config) {
func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, cfg *config.Config, state *wastate.State) {
apiMux := http.NewServeMux()
// Send message
@@ -898,10 +1170,10 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port
return
}
fmt.Println("Received request to send message", req.Message, req.MediaPath)
slog.Info("received request to send message", "message", req.Message, "media_path", req.MediaPath)
success, message := sendWhatsAppMessage(client, req.Recipient, req.Message, req.MediaPath)
fmt.Println("Message sent", success, message)
slog.Info("message sent", "success", success, "message", message)
w.Header().Set("Content-Type", "application/json")
if !success {
@@ -1213,17 +1485,46 @@ func startRESTServer(client *whatsmeow.Client, messageStore *MessageStore, port
})
})
apiMux.HandleFunc("/auth/status", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
respondJSON(w, http.StatusOK, map[string]any{
"connected": state.Connected(),
"logged_in": state.LoggedIn(),
"pairing_required": state.PairingRequired(),
"wa_version": state.WAVersion(),
})
})
apiMux.HandleFunc("/auth/pairing-qr", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
png := state.PairingQRPNG()
if png == nil {
http.Error(w, "no pairing QR available; client is logged in or has not started pairing yet", http.StatusGone)
return
}
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(png)
})
// Authentication
protected := auth.JwtAuthMiddleware(cfg, apiMux)
http.Handle("/api/", http.StripPrefix("/api", protected))
http.Handle("/auth/login", auth.LoginHandler(cfg))
serverAddr := fmt.Sprintf(":%d", port)
fmt.Printf("Starting REST API server on %s...\n", serverAddr)
serverAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
slog.Info("starting REST API server", "addr", serverAddr)
go func() {
if err := http.ListenAndServe(serverAddr, nil); err != nil {
fmt.Printf("REST API server error: %v\n", err)
slog.Error("rest api server error", "err", err)
}
}()
}
@@ -1308,7 +1609,7 @@ func GetChatName(client *whatsmeow.Client, messageStore *MessageStore, jid types
// Handle history sync events
func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, historySync *events.HistorySync, logger waLog.Logger) {
fmt.Printf("Received history sync event with %d conversations\n", len(historySync.Data.Conversations))
slog.Info("received history sync event", "conversations", len(historySync.Data.Conversations))
syncedCount := 0
for _, conversation := range historySync.Data.Conversations {
@@ -1323,6 +1624,8 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his
logger.Warnf("Failed to parse JID %s: %v", chatJID, err)
continue
}
jid = normalizeUserJID(client, jid)
chatJID = jid.String()
name := GetChatName(client, messageStore, jid, chatJID, conversation, "", logger)
@@ -1377,7 +1680,11 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his
isFromMe = *msg.Message.Key.FromMe
}
if !isFromMe && msg.Message.Key.Participant != nil && *msg.Message.Key.Participant != "" {
sender = *msg.Message.Key.Participant
if pJid, err := types.ParseJID(*msg.Message.Key.Participant); err == nil {
sender = normalizeUserJID(client, pJid).User
} else {
sender = *msg.Message.Key.Participant
}
} else if isFromMe {
sender = client.Store.ID.User
} else {
@@ -1430,29 +1737,29 @@ func handleHistorySync(client *whatsmeow.Client, messageStore *MessageStore, his
}
}
fmt.Printf("History sync complete. Stored %d messages.\n", syncedCount)
slog.Info("history sync complete", "stored_messages", syncedCount)
}
// Request history sync from the server
func requestHistorySync(client *whatsmeow.Client) {
if client == nil {
fmt.Println("Client is not initialized. Cannot request history sync.")
slog.Error("client is not initialized, cannot request history sync")
return
}
if !client.IsConnected() {
fmt.Println("Client is not connected. Please ensure you are connected to WhatsApp first.")
slog.Warn("client is not connected to whatsapp")
return
}
if client.Store.ID == nil {
fmt.Println("Client is not logged in. Please scan the QR code first.")
slog.Warn("client is not logged in, please scan the qr code")
return
}
historyMsg := client.BuildHistorySyncRequest(nil, 100)
if historyMsg == nil {
fmt.Println("Failed to build history sync request.")
slog.Error("failed to build history sync request")
return
}
@@ -1462,9 +1769,9 @@ func requestHistorySync(client *whatsmeow.Client) {
}, historyMsg)
if err != nil {
fmt.Printf("Failed to request history sync: %v\n", err)
slog.Error("failed to request history sync", "err", err)
} else {
fmt.Println("History sync requested. Waiting for server response...")
slog.Info("history sync requested, waiting for server response")
}
}
@@ -1515,7 +1822,7 @@ func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) {
preSkip = binary.LittleEndian.Uint16(pageData[headPos+10 : headPos+12])
sampleRate = binary.LittleEndian.Uint32(pageData[headPos+12 : headPos+16])
foundOpusHead = true
fmt.Printf("Found OpusHead: sampleRate=%d, preSkip=%d\n", sampleRate, preSkip)
slog.Info("found OpusHead", "sample_rate", sampleRate, "pre_skip", preSkip)
}
}
}
@@ -1528,16 +1835,15 @@ func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) {
}
if !foundOpusHead {
fmt.Println("Warning: OpusHead not found, using default values")
slog.Warn("opushead not found, using default values")
}
if lastGranule > 0 {
durationSeconds := float64(lastGranule-uint64(preSkip)) / float64(sampleRate)
duration = uint32(math.Ceil(durationSeconds))
fmt.Printf("Calculated Opus duration from granule: %f seconds (lastGranule=%d)\n",
durationSeconds, lastGranule)
slog.Info("calculated Opus duration from granule", "duration_seconds", durationSeconds, "last_granule", lastGranule)
} else {
fmt.Println("Warning: No valid granule position found, using estimation")
slog.Warn("no valid granule position found, using estimation")
durationEstimate := float64(len(data)) / 2000.0
duration = uint32(durationEstimate)
}
@@ -1550,8 +1856,7 @@ func analyzeOggOpus(data []byte) (duration uint32, waveform []byte, err error) {
waveform = placeholderWaveform(duration)
fmt.Printf("Ogg Opus analysis: size=%d bytes, calculated duration=%d sec, waveform=%d bytes\n",
len(data), duration, len(waveform))
slog.Info("ogg opus analysis complete", "size_bytes", len(data), "duration_sec", duration, "waveform_bytes", len(waveform))
return duration, waveform, nil
}
@@ -2381,8 +2686,11 @@ func main() {
return
}
slog.SetDefault(bridgelogger.New(os.Getenv("LOG_LEVEL")))
cfg, err := config.LoadConfig()
if err != nil {
logger.Errorf("Failed to load config: %v", err)
return
}
@@ -2412,11 +2720,14 @@ func main() {
}
}
state := wastate.New()
version, err := CustomGetLatestVersion(context.Background(), nil)
if err != nil {
logger.Errorf("Failed to retrieve current WhatsApp Web client Version")
} else {
store.SetWAVersion(*version)
state.SetWAVersion(fmt.Sprintf("%d.%d.%d", version[0], version[1], version[2]))
logger.Infof("WhatsApp Web Client Version: %d.%d.%d\n", version[0], version[1], version[2])
}
client := whatsmeow.NewClient(deviceStore, logger)
@@ -2435,6 +2746,12 @@ func main() {
}
defer messageStore.Close()
state.SetLoggedIn(client.Store.ID != nil) // existing session means already logged in
const maxOutdatedRetries = 3
var outdatedRetries int
var outdatedRetriesMu sync.Mutex
client.AddEventHandler(func(evt interface{}) {
switch v := evt.(type) {
case *events.Message:
@@ -2445,58 +2762,112 @@ func main() {
case *events.Connected:
logger.Infof("Connected to WhatsApp")
state.SetConnected(true)
state.SetLoggedIn(true)
state.ClearPairingQR()
outdatedRetriesMu.Lock()
outdatedRetries = 0
outdatedRetriesMu.Unlock()
case *events.Disconnected:
logger.Warnf("Disconnected from WhatsApp")
state.SetConnected(false)
case *events.LoggedOut:
logger.Warnf("Device logged out, please scan QR code to log in again")
state.SetLoggedIn(false)
state.SetConnected(false)
case *events.ClientOutdated:
outdatedRetriesMu.Lock()
outdatedRetries++
n := outdatedRetries
outdatedRetriesMu.Unlock()
state.SetConnected(false)
if n > maxOutdatedRetries {
slog.Error("client outdated: exceeded retry budget; whatsmeow library likely needs a real upgrade",
"retries", n, "max", maxOutdatedRetries)
return
}
slog.Warn("client outdated (405); refreshing wa version and reconnecting",
"attempt", n, "max", maxOutdatedRetries)
go func() {
time.Sleep(5 * time.Second)
newVersion, err := CustomGetLatestVersion(context.Background(), nil)
if err != nil {
slog.Error("failed to refresh wa version", "err", err)
return
}
store.SetWAVersion(*newVersion)
state.SetWAVersion(fmt.Sprintf("%d.%d.%d", newVersion[0], newVersion[1], newVersion[2]))
slog.Info("applied refreshed wa version, attempting reconnect", "version", state.WAVersion())
if err := client.Connect(); err != nil {
slog.Error("reconnect after wa version refresh failed", "err", err)
}
}()
}
})
connected := make(chan bool, 1)
migrateLIDChatsToPhoneJIDs(client, messageStore, logger, cfg.DB.IsPostgres)
if client.Store.ID == nil {
qrChan, _ := client.GetQRChannel(context.Background())
err = client.Connect()
if err != nil {
logger.Errorf("Failed to connect: %v", err)
return
}
// REST server comes up first so /api/auth/status and /api/auth/pairing-qr
// are reachable during pairing. WhatsApp connect runs concurrently below.
startRESTServer(client, messageStore, cfg, state)
for evt := range qrChan {
if evt.Event == "code" {
fmt.Println("\nScan this QR code with your WhatsApp app:")
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
} else if evt.Event == "success" {
connected <- true
break
// Periodically refresh the WhatsApp Web client version so reconnects
// after transient drops use a current version string. Only the next
// connection picks up the refreshed value; the active session is unaffected.
go func() {
t := time.NewTicker(6 * time.Hour)
defer t.Stop()
for range t.C {
v, err := CustomGetLatestVersion(context.Background(), nil)
if err != nil {
slog.Warn("periodic wa version refresh failed", "err", err)
continue
}
store.SetWAVersion(*v)
next := fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2])
if next != state.WAVersion() {
slog.Info("wa version updated by periodic refresh", "from", state.WAVersion(), "to", next)
state.SetWAVersion(next)
}
}
}()
select {
case <-connected:
fmt.Println("\nSuccessfully connected and authenticated!")
case <-time.After(3 * time.Minute):
logger.Errorf("Timeout waiting for QR code scan")
return
// Pair / connect to WhatsApp in a goroutine so main can block on signals.
go func() {
if client.Store.ID == nil {
qrChan, _ := client.GetQRChannel(context.Background())
if err := client.Connect(); err != nil {
logger.Errorf("Failed to connect: %v", err)
return
}
for evt := range qrChan {
switch evt.Event {
case "code":
fmt.Println("\nScan this QR code with your WhatsApp app:")
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
if png, err := qrcode.Encode(evt.Code, qrcode.Medium, 256); err == nil {
state.SetPairingQRPNG(png)
} else {
slog.Warn("failed to encode pairing qr as png", "err", err)
}
case "success":
fmt.Println("\nSuccessfully connected and authenticated!")
return
case "timeout":
logger.Errorf("Pairing QR timeout")
return
}
}
} else {
if err := client.Connect(); err != nil {
logger.Errorf("Failed to connect: %v", err)
return
}
}
} else {
err = client.Connect()
if err != nil {
logger.Errorf("Failed to connect: %v", err)
return
}
connected <- true
}
time.Sleep(2 * time.Second)
if !client.IsConnected() {
logger.Errorf("Failed to establish stable connection")
return
}
fmt.Println("\n✓ Connected to WhatsApp! Type 'help' for commands.")
startRESTServer(client, messageStore, 8080, cfg)
}()
exitChan := make(chan os.Signal, 1)
signal.Notify(exitChan, syscall.SIGINT, syscall.SIGTERM)