feat: geolocate anonymous widget visitors
Build and publish Docker images / Build and publish images (push) Successful in 2m10s
Build and publish Docker images / Build and publish images (push) Successful in 2m10s
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user