feat: geolocate anonymous widget visitors
Build and publish Docker images / Build and publish images (push) Successful in 2m10s

This commit is contained in:
2026-09-15 08:54:13 +08:00
parent 5a0e9ecada
commit 4d684a71eb
28 changed files with 1502 additions and 104 deletions
@@ -0,0 +1,101 @@
package widget
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gochat/gochat/internal/geoip"
"github.com/gochat/gochat/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
)
type handlerGeoResolver struct {
location geoip.Location
seenIP net.IP
}
func (r *handlerGeoResolver) Lookup(ip net.IP) (geoip.Location, bool) {
r.seenIP = append(net.IP(nil), ip...)
return r.location, true
}
func TestWidgetHandler_ConfigUsesServerClientIPForAnonymousName(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
resolver := &handlerGeoResolver{location: geoip.Location{Province: "河北省", City: "保定市"}}
handler.widgetService.SetVisitorGeoResolver(resolver)
recorder := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodPost, "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
require.NoError(t, err)
req.RemoteAddr = "203.0.113.20:4567"
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "203.0.113.20", resolver.seenIP.String())
var payload map[string]any
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
contact, ok := payload["contact"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "河北保定客户", contact["name"])
}
func TestWidgetHandler_InitIgnoresClientSuppliedIP(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
require.NoError(t, router.SetTrustedProxies(nil))
resolver := &handlerGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}}
handler.widgetService.SetVisitorGeoResolver(resolver)
recorder := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodPost, "/widget/init", strings.NewReader(`{"website_token":"handler_ws_token_123","client_ip":"198.51.100.99"}`))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "198.51.100.99")
req.RemoteAddr = "203.0.113.21:4567"
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "203.0.113.21", resolver.seenIP.String())
var contact model.Contact
require.NoError(t, db.First(&contact).Error)
assert.Equal(t, "北京客户", contact.Name)
}
func TestWidgetHandler_ConfigHonorsConfiguredTrustedProxyIP(t *testing.T) {
db, router, handler := setupWidgetHandlerTest(t)
_, _ = seedWidgetHandlerData(t, db)
require.NoError(t, router.SetTrustedProxies([]string{"203.0.113.0/24"}))
resolver := &handlerGeoResolver{location: geoip.Location{Province: "北京市", City: "北京市"}}
handler.widgetService.SetVisitorGeoResolver(resolver)
recorder := httptest.NewRecorder()
req, err := http.NewRequest(http.MethodPost, "/api/v1/widget/config?website_token=handler_ws_token_123", nil)
require.NoError(t, err)
req.Header.Set("X-Forwarded-For", "198.51.100.44")
req.RemoteAddr = "203.0.113.21:4567"
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "198.51.100.44", resolver.seenIP.String())
}
func TestWidgetContactFullPayloadDoesNotExposeVisitorMetadata(t *testing.T) {
contact := &model.Contact{
Name: "河北保定客户",
AdditionalAttributes: datatypes.JSON(`{"visitor_name_source":"ip_geolocation","visitor_province":"河北","visitor_city":"保定","customer_note":"keep"}`),
}
payload := widgetContactFullPayload(contact)
attributes, ok := payload["additional_attributes"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "keep", attributes["customer_note"])
assert.NotContains(t, attributes, "visitor_name_source")
assert.NotContains(t, attributes, "visitor_province")
assert.NotContains(t, attributes, "visitor_city")
}
@@ -120,6 +120,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
if identifierHash == "" {
identifierHash = c.Query("identifier_hash")
}
req.ClientIP = c.ClientIP()
if req.Identifier != "" && identifierHash != "" {
// Resolve the inbox to get its hmac_token for verification
inbox, err := h.widgetService.GetInboxByWebsiteToken(c.Request.Context(), req.WebsiteToken)
@@ -159,6 +160,7 @@ func (h *WidgetHandler) Config(c *gin.Context) {
if req.WidgetToken == "" {
req.WidgetToken = widgetTokenFromRequest(c)
}
req.ClientIP = c.ClientIP()
resp, err := h.widgetService.Init(c.Request.Context(), req)
if err != nil {
@@ -631,6 +633,7 @@ func (h *WidgetHandler) SetUser(c *gin.Context) {
PhoneNumber: req.PhoneNumber,
CustomAttributes: req.CustomAttributes,
AdditionalAttributes: req.AdditionalAttributes,
ClientIP: c.ClientIP(),
})
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
@@ -987,6 +990,7 @@ func (h *WidgetHandler) PublicCreateContact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
req.ClientIP = c.ClientIP()
resp, err := h.widgetService.PublicCreateContact(c.Request.Context(), c.Param("inbox_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
@@ -1010,6 +1014,7 @@ func (h *WidgetHandler) PublicUpdateContact(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
return
}
req.ClientIP = c.ClientIP()
resp, err := h.widgetService.PublicUpdateContact(c.Request.Context(), c.Param("inbox_id"), c.Param("contact_id"), req)
if err != nil {
c.JSON(widgetErrorStatus(err), gin.H{"error": err.Error()})
@@ -1205,6 +1210,7 @@ func (h *WidgetHandler) SubmitOfflineMessage(c *gin.Context) {
referer := c.Request.Referer()
browserInfo := c.GetHeader("User-Agent")
submission.ClientIP = c.ClientIP()
msg, err := h.widgetService.SubmitOfflineMessage(
c.Request.Context(),
@@ -1428,7 +1434,7 @@ func widgetContactFullPayload(contact *model.Contact) gin.H {
"avatar_url": contact.AvatarURL,
"identifier": contact.Identifier,
"custom_attributes": contact.CustomAttributes,
"additional_attributes": contact.AdditionalAttributes,
"additional_attributes": service.PublicWidgetAdditionalAttributes(contact.AdditionalAttributes),
}
}
@@ -1548,7 +1548,7 @@ func TestWidgetHandler_ChatwootConversationHeadActionsReturnEmptyOK(t *testing.T
var activity model.Message
require.NoError(t, db.Where("message_type = ?", string(model.MessageTypeActivity)).First(&activity).Error)
assert.Equal(t, "Conversation was resolved by Anonymous Visitor", activity.Content)
assert.Equal(t, "Conversation was resolved by 匿名客户", activity.Content)
}
func TestWidgetHandler_ChatwootToggleStatusHonorsEndConversationFlag(t *testing.T) {