From caf4b6c7dcab6f9d24d70f25f1f66811dd53a608 Mon Sep 17 00:00:00 2001 From: prakash-dev-code Date: Wed, 26 Aug 2026 20:06:42 +0000 Subject: [PATCH] feat(sender): add chat-style UI for sending and receiving messages The bundled manager (manager/dist) is a prebuilt artifact that manages instances but has no messaging screen, so there is currently no first-party way to send a message from a browser. Testing a paired instance means reaching for curl, Swagger or Postman, and re-entering the recipient number on every send. This adds a self-contained page at /sender: a chat list of saved recipients, per-conversation history, and a composer. Numbers are stored in the browser, so a recipient is reused with one click instead of being retyped. Implementation notes: - Plain HTML/CSS/JS in web/sender/index.html with no build step, so the repository gains no frontend toolchain. It is kept out of manager/dist because that bundle is generated upstream and would overwrite it. - Registered from main.go via sender_handler.RegisterRoutes rather than pkg/routes, because the page needs *config.Config to bootstrap itself. This mirrors how the passkey ceremony routes are wired. - Routes: GET /sender (plus /sender/ and /chat aliases) and a GET / redirect, since "/" was previously a 404 and the page is easy to miss. - Uses only existing endpoints: /instance/all, /instance/connect, /send/text, /send/media, /message/downloadmedia and the /ws socket. No changes to existing handlers or services. - Live updates arrive over the existing websocket producer. Inbound media renders from the base64 payload when MINIO_ENABLED is false; the media descriptor is kept so bytes can be re-fetched through /message/downloadmedia after a reload rather than persisting them. - Chats addressed by LID (for example 186896156205308@lid) are resolved to phone numbers via a new GET /sender/resolve-lids endpoint backed by the whatsmeow lid map, otherwise a conversation can only be labelled with an identifier that means nothing to a user. The endpoint requires the global API key because it discloses phone numbers, accepts numeric input only, and returns an empty object when the mapping is unavailable, such as on SQLite deployments. - The global API key is embedded in the page only for loopback requests, so a LAN or proxied client must enter it manually. RemoteIP is used rather than ClientIP, which honours X-Forwarded-For and can be spoofed. Setting SENDER_DISABLE_KEY_AUTOFILL turns the behaviour off entirely. Message bodies are rendered with textContent, never innerHTML, so inbound content cannot inject markup. --- Dockerfile | 1 + cmd/evolution-go/main.go | 5 + pkg/routes/routes.go | 4 + pkg/sender/handler/sender_handler.go | 223 ++++ web/sender/index.html | 1398 ++++++++++++++++++++++++++ 5 files changed, 1631 insertions(+) create mode 100644 pkg/sender/handler/sender_handler.go create mode 100644 web/sender/index.html diff --git a/Dockerfile b/Dockerfile index 462ed49d..169fee8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ WORKDIR /app COPY --from=build /build/server . COPY --from=build /build/manager/dist ./manager/dist +COPY --from=build /build/web ./web COPY --from=build /build/VERSION ./VERSION ENV TZ=America/Sao_Paulo diff --git a/cmd/evolution-go/main.go b/cmd/evolution-go/main.go index 5234583f..0c0d2d08 100644 --- a/cmd/evolution-go/main.go +++ b/cmd/evolution-go/main.go @@ -57,6 +57,7 @@ import ( routes "github.com/evolution-foundation/evolution-go/pkg/routes" send_handler "github.com/evolution-foundation/evolution-go/pkg/sendMessage/handler" send_service "github.com/evolution-foundation/evolution-go/pkg/sendMessage/service" + sender_handler "github.com/evolution-foundation/evolution-go/pkg/sender/handler" server_handler "github.com/evolution-foundation/evolution-go/pkg/server/handler" storage_interfaces "github.com/evolution-foundation/evolution-go/pkg/storage/interfaces" minio_storage "github.com/evolution-foundation/evolution-go/pkg/storage/minio" @@ -241,6 +242,10 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C server_handler.NewServerHandler(), ).AssignRoutes(r) + // Chat-style sender UI (/, /sender, /chat). Registered here rather than in + // pkg/routes because it bootstraps the page from *config.Config. + sender_handler.RegisterRoutes(r, config) + if config.ConnectOnStartup { go whatsmeowService.ConnectOnStartup(config.ClientName) } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 8e026a87..3696376d 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -75,6 +75,10 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { c.File("manager/dist/index.html") }) + // NOTE: the chat-style sender UI (/sender, /chat and the "/" redirect) is + // registered from main.go via sender_handler.RegisterRoutes, because it needs + // *config.Config to bootstrap the page. Same pattern as the passkey routes. + eng.GET("/server/ok", r.serverHandler.ServerOk) routes := eng.Group("/instance") diff --git a/pkg/sender/handler/sender_handler.go b/pkg/sender/handler/sender_handler.go new file mode 100644 index 00000000..2d343b10 --- /dev/null +++ b/pkg/sender/handler/sender_handler.go @@ -0,0 +1,223 @@ +// Package handler serves the chat-style sender UI: a small standalone page that +// talks to the regular REST API from the browser so messages can be sent without +// retyping a recipient every time. +// +// It is registered from main.go (not pkg/routes) because it needs *config.Config +// to bootstrap the page, mirroring how the passkey ceremony routes are wired. +// +// Routes: +// +// GET / -> 302 to /sender +// GET /sender -> the page +// GET /sender/ -> the page +// GET /chat -> the page +package handler + +import ( + "database/sql" + "encoding/json" + "net" + "net/http" + "os" + "strings" + "sync" + + "github.com/evolution-foundation/evolution-go/pkg/config" + "github.com/gin-gonic/gin" + _ "github.com/lib/pq" +) + +const pagePath = "web/sender/index.html" + +// bootstrapToken is replaced with a JSON blob before the page is sent. +const bootstrapToken = "__EVO_BOOTSTRAP__" + +// bootstrap is handed to the page so it can configure itself without the user +// pasting credentials by hand. +type bootstrap struct { + // APIKey is the global API key, populated ONLY for loopback requests. + APIKey string `json:"apiKey"` + // KeyInjected reports whether APIKey was filled in, so the page can explain + // itself instead of silently showing an empty settings form. + KeyInjected bool `json:"keyInjected"` +} + +type senderHandler struct { + config *config.Config + + // authDB is opened lazily and only to read whatsmeow_lid_map. main.go's + // authDB handle is nil whenever POSTGRES_AUTH_DB is set, so we cannot borrow + // it and open our own instead. + authOnce sync.Once + authDB *sql.DB +} + +// lidStore returns a handle to the whatsmeow auth database, or nil when the +// service is running on SQLite (no POSTGRES_AUTH_DB configured). +func (h *senderHandler) lidStore() *sql.DB { + h.authOnce.Do(func() { + if h.config.PostgresAuthDB == "" { + return + } + db, err := sql.Open("postgres", h.config.PostgresAuthDB) + if err != nil { + return + } + db.SetMaxOpenConns(2) + if err := db.Ping(); err != nil { + _ = db.Close() + return + } + h.authDB = db + }) + return h.authDB +} + +// resolveLIDs maps WhatsApp LIDs (privacy identifiers such as 186896156205308) +// back to real phone numbers using the mapping whatsmeow maintains while it +// syncs. Without this the chat UI can only label a conversation with the LID, +// which is meaningless to a human. +// +// Requires the global API key: the response discloses phone numbers, so it must +// not be readable by anything that can merely reach the port. +// +// @Summary Resolve WhatsApp LIDs to phone numbers +// @Description Translates WhatsApp LID privacy identifiers into phone numbers using the whatsmeow LID mapping. Requires the global API key because the response discloses phone numbers. Returns an empty object when the mapping is unavailable (SQLite deployments) or when no LID matches. +// @Tags Sender +// @Produce json +// @Param lids query string true "Comma-separated list of numeric LIDs" +// @Success 200 {object} map[string]string "Map of LID to phone number" +// @Failure 401 {object} gin.H "not authorized" +// @Router /sender/resolve-lids [get] +func (h *senderHandler) resolveLIDs(c *gin.Context) { + if c.GetHeader("apikey") != h.config.GlobalApiKey || h.config.GlobalApiKey == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) + return + } + + out := gin.H{} + + raw := strings.Split(c.Query("lids"), ",") + var wanted []interface{} + for _, v := range raw { + v = strings.TrimSpace(v) + // Digits only: these values are interpolated into a query, and a LID is + // always numeric, so anything else is rejected outright. + if v == "" || strings.IndexFunc(v, func(r rune) bool { return r < '0' || r > '9' }) != -1 { + continue + } + wanted = append(wanted, v) + } + if len(wanted) == 0 { + c.JSON(http.StatusOK, out) + return + } + + db := h.lidStore() + if db == nil { + c.JSON(http.StatusOK, out) // mapping unavailable; caller keeps showing the LID + return + } + + placeholders := make([]string, len(wanted)) + for i := range wanted { + placeholders[i] = "$" + itoa(i+1) + } + query := "SELECT lid, pn FROM whatsmeow_lid_map WHERE lid IN (" + strings.Join(placeholders, ",") + ")" + + rows, err := db.QueryContext(c.Request.Context(), query, wanted...) + if err != nil { + c.JSON(http.StatusOK, out) + return + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var lid, pn string + if err := rows.Scan(&lid, &pn); err == nil { + out[lid] = pn + } + } + c.JSON(http.StatusOK, out) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [12]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} + +// isLoopback reports whether the request came from this machine. +// +// The global API key is only ever embedded for loopback callers. RemoteIP is the +// real socket peer (unlike ClientIP, which trusts X-Forwarded-For and could be +// spoofed), so a LAN or proxied request never receives the key and has to use +// the settings form like any other client. +func isLoopback(c *gin.Context) bool { + ip := net.ParseIP(c.RemoteIP()) + return ip != nil && ip.IsLoopback() +} + +// keyAutofillDisabled lets an operator switch off key embedding entirely, even +// for loopback requests, by setting SENDER_DISABLE_KEY_AUTOFILL to a truthy +// value. Useful when the host is shared or reachable through a local tunnel. +// Read straight from the environment, mirroring PASSKEY_PUBLIC_URL. +func keyAutofillDisabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("SENDER_DISABLE_KEY_AUTOFILL"))) { + case "1", "true", "yes", "enabled": + return true + } + return false +} + +func (h *senderHandler) page(c *gin.Context) { + raw, err := os.ReadFile(pagePath) + if err != nil { + c.String(http.StatusInternalServerError, + "sender UI not found at %s — run the server from the repository root", pagePath) + return + } + + boot := bootstrap{} + if isLoopback(c) && !keyAutofillDisabled() { + boot.APIKey = h.config.GlobalApiKey + boot.KeyInjected = boot.APIKey != "" + } + + // json.Marshal escapes <, > and & to \u003c/\u003e/\u0026, so the blob is + // safe to drop inside a + + + +