mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-10 14:15:42 +00:00
19146541e4
The country-code length error borrowed the phone number label, so a too-long country code read as a phone number limit. Adds a countryCode term for it. ConversationsView also offered the new-conversation button when prevent_multiple_conversations was set, without checking whether visitors may start one at all, so it now follows the same rule as the home screen and the server.
1312 lines
49 KiB
Go
1312 lines
49 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"math"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/abhinavxd/libredesk/internal/attachment"
|
|
bhmodels "github.com/abhinavxd/libredesk/internal/business_hours/models"
|
|
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
|
"github.com/abhinavxd/libredesk/internal/envelope"
|
|
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
|
|
imodels "github.com/abhinavxd/libredesk/internal/inbox/models"
|
|
"github.com/abhinavxd/libredesk/internal/stringutil"
|
|
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
|
realip "github.com/ferluci/fast-realip"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/volatiletech/null/v9"
|
|
"github.com/zerodha/fastglue"
|
|
)
|
|
|
|
const (
|
|
maxChatConversationsPerContact = 50
|
|
chatConversationRateLimitWindow = 24 * time.Hour
|
|
widgetSessionPrefix = "widget_session:"
|
|
defaultSessionTTL = 180 * 24 * time.Hour
|
|
minSessionTTL = 1 * time.Hour
|
|
maxChatMessageLength = 10000
|
|
maxEmailLength = 254
|
|
maxNameLength = 128
|
|
maxExternalUserIDLength = 128
|
|
maxPhoneNumberLength = 20
|
|
maxPhoneCountryCodeLength = 10
|
|
fieldTypePhone = "phone"
|
|
phoneCountryCodeSuffix = "_country_code"
|
|
)
|
|
|
|
// WidgetSession holds session data stored in Redis.
|
|
type WidgetSession struct {
|
|
UserID int
|
|
InboxID int
|
|
IsVisitor bool
|
|
ExternalUserID string
|
|
}
|
|
|
|
// Claims holds JWT claims for a JWT user.
|
|
type Claims struct {
|
|
UserID int `json:"user_id,omitempty"`
|
|
ExternalUserID string `json:"external_user_id,omitempty"`
|
|
IsVisitor bool `json:"is_visitor,omitempty"`
|
|
Email string `json:"email,omitempty"`
|
|
FirstName string `json:"first_name,omitempty"`
|
|
LastName string `json:"last_name,omitempty"`
|
|
PhoneNumber string `json:"phone_number,omitempty"`
|
|
PhoneNumberCountryCode string `json:"phone_number_country_code,omitempty"`
|
|
ContactCustomAttributes map[string]any `json:"contact_custom_attributes,omitempty"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
type conversationResp struct {
|
|
Conversation cmodels.ChatConversation `json:"conversation"`
|
|
Messages []cmodels.ChatMessage `json:"messages"`
|
|
}
|
|
|
|
type customAttributeWidget struct {
|
|
ID int `json:"id"`
|
|
Values []string `json:"values"`
|
|
Name string `json:"-"`
|
|
DataType string `json:"-"`
|
|
}
|
|
|
|
type chatInitReq struct {
|
|
Message string `json:"message"`
|
|
FormData map[string]any `json:"form_data"`
|
|
}
|
|
|
|
type chatSettingsResponse struct {
|
|
livechat.Config
|
|
// Hide server-side fields from the public widget response.
|
|
TrustedDomains *struct{} `json:"trusted_domains,omitempty"`
|
|
BlockedIPs *struct{} `json:"blocked_ips,omitempty"`
|
|
Continuity *struct{} `json:"continuity,omitempty"`
|
|
SessionDuration *struct{} `json:"session_duration,omitempty"`
|
|
BusinessHours []bhmodels.BusinessHours `json:"business_hours,omitempty"`
|
|
DefaultBusinessHoursID int `json:"default_business_hours_id,omitempty"`
|
|
WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"`
|
|
CustomAttributes map[int]customAttributeWidget `json:"custom_attributes,omitempty"`
|
|
}
|
|
|
|
// conversationResponseWithBusinessHours includes business hours info for the widget
|
|
type conversationResponseWithBusinessHours struct {
|
|
conversationResp
|
|
BusinessHoursID *int `json:"business_hours_id,omitempty"`
|
|
WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"`
|
|
}
|
|
|
|
// handleGetChatLauncherSettings returns the live chat launcher settings for the widget.
|
|
func handleGetChatLauncherSettings(r *fastglue.Request) error {
|
|
r.RequestCtx.Response.Header.Set("Access-Control-Allow-Origin", "*")
|
|
config, err := getWidgetConfig(r)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil))
|
|
}
|
|
|
|
return r.SendEnvelope(map[string]any{
|
|
"launcher": config.Launcher,
|
|
"colors": config.Colors,
|
|
})
|
|
}
|
|
|
|
// handleGetChatSettings returns the live chat settings for the widget
|
|
func handleGetChatSettings(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
|
|
config, err := getWidgetConfig(r)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, err.Error(), nil))
|
|
}
|
|
|
|
response := chatSettingsResponse{
|
|
Config: config,
|
|
}
|
|
|
|
// Get business hours data if office hours feature is enabled.
|
|
if config.ShowOfficeHoursInChat {
|
|
businessHours, err := app.businessHours.GetAll()
|
|
if err != nil {
|
|
app.lo.Error("error fetching business hours", "error", err)
|
|
} else {
|
|
response.BusinessHours = businessHours
|
|
}
|
|
|
|
// Get default business hours ID and UTC offset from general settings.
|
|
out, err := app.setting.GetByPrefix("app")
|
|
if err != nil {
|
|
app.lo.Error("error fetching general settings", "error", err)
|
|
} else {
|
|
var settings map[string]any
|
|
if err := json.Unmarshal(out, &settings); err == nil {
|
|
if bhID, ok := settings["app.business_hours_id"].(string); ok {
|
|
response.DefaultBusinessHoursID, _ = strconv.Atoi(bhID)
|
|
}
|
|
if tz, ok := settings["app.timezone"].(string); ok && tz != "" {
|
|
if loc, err := time.LoadLocation(tz); err == nil {
|
|
_, offset := time.Now().In(loc).Zone()
|
|
offsetMinutes := offset / 60
|
|
response.WorkingHoursUTCOffset = &offsetMinutes
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter out pre-chat form fields for which custom attributes don't exist anymore.
|
|
if config.PreChatForm.Enabled && len(config.PreChatForm.Fields) > 0 {
|
|
filteredFields, customAttributes := filterPreChatFormFields(config.PreChatForm.Fields, app)
|
|
response.PreChatForm.Fields = filteredFields
|
|
if len(customAttributes) > 0 {
|
|
response.CustomAttributes = customAttributes
|
|
}
|
|
}
|
|
|
|
return r.SendEnvelope(response)
|
|
}
|
|
|
|
// handleChatInit initializes a new chat session.
|
|
func handleChatInit(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
req = chatInitReq{}
|
|
clientIP = realip.FromRequest(r.RequestCtx)
|
|
userAgent = string(r.RequestCtx.Request.Header.Peek("User-Agent"))
|
|
contactID int
|
|
isVisitor bool
|
|
newSessionToken string
|
|
conversationAttrs map[string]any
|
|
visitor umodels.User
|
|
)
|
|
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
app.lo.Error("error unmarshalling chat init request", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
|
}
|
|
|
|
if req.Message == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError)
|
|
}
|
|
if len(req.Message) > maxChatMessageLength {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxChatMessageLength)), nil, envelope.InputError)
|
|
}
|
|
|
|
inbox, err := getWidgetInbox(r)
|
|
if err != nil {
|
|
app.lo.Error("error getting inbox from middleware context", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
config, err := getWidgetConfig(r)
|
|
if err != nil {
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
// Check if user is already authenticated (has session token).
|
|
contactID, _ = getWidgetContactID(r)
|
|
if contactID > 0 {
|
|
// Returning user (visitor or contact) with session token.
|
|
// Custom attributes from JWT were already saved during /auth/exchange.
|
|
// Only process form-level attributes here.
|
|
isVisitor = getWidgetIsVisitor(r)
|
|
conversationAttrs = saveContactAttrsAndCollectConvoAttrs(app, contactID, nil, req.FormData, config)
|
|
} else {
|
|
// New visitor - create visitor and session token.
|
|
isVisitor = true
|
|
visitor, newSessionToken, conversationAttrs, err = createVisitorContact(app, req.FormData, config, inbox)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
contactID = visitor.ID
|
|
}
|
|
|
|
// Check conversation permissions based on user type.
|
|
if err := checkConversationPermissions(app, config, isVisitor, contactID, inbox.ID); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", inbox.ID, "is_visitor", isVisitor)
|
|
|
|
// Create conversation and insert message.
|
|
meta := map[string]any{
|
|
"ip": clientIP,
|
|
"user_agent": userAgent,
|
|
}
|
|
_, conversationUUID, err := app.conversation.CreateConversation(
|
|
contactID,
|
|
inbox.ID,
|
|
"",
|
|
time.Now(),
|
|
"",
|
|
false,
|
|
meta,
|
|
conversationAttrs,
|
|
maxChatConversationsPerContact,
|
|
chatConversationRateLimitWindow,
|
|
)
|
|
if err != nil {
|
|
if envErr, ok := err.(envelope.Error); ok && envErr.ErrorType == envelope.RateLimitError {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
app.lo.Error("error creating conversation", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingMessage"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
message := cmodels.Message{
|
|
ConversationUUID: conversationUUID,
|
|
SenderID: contactID,
|
|
Type: cmodels.MessageIncoming,
|
|
SenderType: cmodels.SenderTypeContact,
|
|
Status: cmodels.MessageStatusReceived,
|
|
Content: req.Message,
|
|
ContentType: cmodels.ContentTypeText,
|
|
Private: false,
|
|
}
|
|
if err := app.conversation.InsertMessage(&message); err != nil {
|
|
// Clean up conversation if message insert fails.
|
|
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
|
|
app.lo.Error("error deleting conversation after message insert failure", "conversation_uuid", conversationUUID, "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingMessage"), nil, envelope.GeneralError)
|
|
}
|
|
app.lo.Error("error inserting initial message", "conversation_uuid", conversationUUID, "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingMessage"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
// Process post-message hooks for the new conversation and initial message.
|
|
if err := app.conversation.ProcessIncomingMessageHooks(conversationUUID, true); err != nil {
|
|
app.lo.Error("error processing incoming message hooks for initial message", "conversation_uuid", conversationUUID, "error", err)
|
|
}
|
|
|
|
conversation, err := app.conversation.GetConversation(0, conversationUUID, "")
|
|
if err != nil {
|
|
app.lo.Error("error fetching created conversation", "conversation_uuid", conversationUUID, "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
// Build response with conversation and messages and add business hours info.
|
|
resp, err := buildConversationResponseWithBusinessHours(app, conversation)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
response := map[string]any{
|
|
"conversation": resp.Conversation,
|
|
"messages": resp.Messages,
|
|
"business_hours_id": resp.BusinessHoursID,
|
|
"working_hours_utc_offset": resp.WorkingHoursUTCOffset,
|
|
}
|
|
|
|
// Add session token and user metadata when a new visitor is created.
|
|
if newSessionToken != "" {
|
|
response["session_token"] = newSessionToken
|
|
response["user"] = map[string]any{
|
|
"user_id": contactID,
|
|
"is_visitor": isVisitor,
|
|
"first_name": visitor.FirstName,
|
|
"last_name": visitor.LastName,
|
|
}
|
|
}
|
|
|
|
return r.SendEnvelope(response)
|
|
}
|
|
|
|
// handleChatUpdateLastSeen updates contact last seen timestamp for a conversation
|
|
func handleChatUpdateLastSeen(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
|
|
)
|
|
|
|
if conversationUUID == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.conversation}"), nil, envelope.InputError)
|
|
}
|
|
|
|
_, conversation, err := getContactConversation(r, conversationUUID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
if err := app.conversation.UpdateConversationContactLastSeen(conversation.UUID); err != nil {
|
|
app.lo.Error("error updating contact last seen timestamp", "conversation_uuid", conversationUUID, "error", err)
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
return r.SendEnvelope(true)
|
|
}
|
|
|
|
// handleAuthExchange exchanges a JWT for a session token.
|
|
// Used by the setUser() flow for verified contacts.
|
|
func handleAuthExchange(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
|
|
inbox, err := getWidgetInbox(r)
|
|
if err != nil {
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
config, err := getWidgetConfig(r)
|
|
if err != nil {
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
var req struct {
|
|
JWT string `json:"jwt"`
|
|
}
|
|
if err := r.Decode(&req, "json"); err != nil || req.JWT == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "jwt"), nil, envelope.InputError)
|
|
}
|
|
|
|
// Verify the customer-generated JWT.
|
|
claims, err := verifyStandardJWT(req.JWT, inbox.Secret.String)
|
|
if err != nil {
|
|
app.lo.Error("invalid JWT in auth exchange", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
|
|
}
|
|
|
|
if claims.ExternalUserID == "" || len(claims.ExternalUserID) > maxExternalUserIDLength {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "external_user_id"), nil, envelope.InputError)
|
|
}
|
|
if claims.Email == "" || len(claims.Email) > maxEmailLength {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "email"), nil, envelope.InputError)
|
|
}
|
|
if claims.FirstName == "" || len(claims.FirstName) > maxNameLength {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "first_name"), nil, envelope.InputError)
|
|
}
|
|
if len(claims.LastName) > maxNameLength {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil, envelope.InputError)
|
|
}
|
|
if len(claims.PhoneNumber) > maxPhoneNumberLength {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil, envelope.InputError)
|
|
}
|
|
// Country code is cosmetic - drop an invalid one instead of failing the whole exchange.
|
|
if len(claims.PhoneNumberCountryCode) > maxPhoneCountryCodeLength {
|
|
claims.PhoneNumberCountryCode = ""
|
|
}
|
|
|
|
// Resolve or create the contact.
|
|
contactID, err := resolveOrCreateExternalContact(app, claims)
|
|
if err != nil {
|
|
app.lo.Error("error resolving contact during auth exchange", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
// Save custom attributes from JWT.
|
|
if len(claims.ContactCustomAttributes) > 0 {
|
|
if err := app.user.SaveCustomAttributes(contactID, claims.ContactCustomAttributes, false); err != nil {
|
|
app.lo.Error("error saving custom attributes during auth exchange", "contact_id", contactID, "error", err)
|
|
}
|
|
}
|
|
|
|
ctx := context.Background()
|
|
reverseKey := fmt.Sprintf("widget_user:%d:%d", inbox.ID, contactID)
|
|
sessionTTL := getSessionDuration(config)
|
|
|
|
sendSession := func(token string) error {
|
|
app.redis.Set(ctx, reverseKey, token, sessionTTL)
|
|
return r.SendEnvelope(map[string]any{
|
|
"session_token": token,
|
|
"user": map[string]any{
|
|
"user_id": contactID,
|
|
"is_visitor": false,
|
|
"first_name": claims.FirstName,
|
|
"last_name": claims.LastName,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Reuse existing valid session and refresh its TTL.
|
|
if oldToken, err := app.redis.Get(ctx, reverseKey).Result(); err == nil && oldToken != "" {
|
|
if _, err := loadSession(app, oldToken, config); err == nil {
|
|
return sendSession(oldToken)
|
|
}
|
|
}
|
|
|
|
// Generate new session token.
|
|
token, err := generateSessionToken(app, contactID, inbox.ID, false, claims.ExternalUserID, sessionTTL)
|
|
if err != nil {
|
|
app.lo.Error("error generating session token", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
return sendSession(token)
|
|
}
|
|
|
|
// handleWidgetAuthMe returns the current authenticated user's metadata.
|
|
func handleWidgetAuthMe(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
|
|
contactID, err := getWidgetContactID(r)
|
|
if err != nil {
|
|
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
|
|
}
|
|
|
|
u, err := app.user.Get(contactID, "", []string{umodels.UserTypeContact, umodels.UserTypeVisitor})
|
|
if err != nil {
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
return r.SendEnvelope(map[string]any{
|
|
"user_id": u.ID,
|
|
"is_visitor": u.Type == umodels.UserTypeVisitor,
|
|
"first_name": u.FirstName,
|
|
"last_name": u.LastName,
|
|
})
|
|
}
|
|
|
|
// handleChatGetConversation fetches a chat conversation by ID
|
|
func handleChatGetConversation(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
|
|
)
|
|
|
|
if conversationUUID == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "conversation_id is required", nil, envelope.InputError)
|
|
}
|
|
|
|
_, conversation, err := getContactConversation(r, conversationUUID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
// Build conversation response with messages and attachments.
|
|
resp, err := buildConversationResponseWithBusinessHours(app, conversation)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
return r.SendEnvelope(resp)
|
|
}
|
|
|
|
// handleGetConversations fetches all chat conversations for a widget user
|
|
func handleGetConversations(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
|
|
// Get authenticated data from middleware context
|
|
contactID, err := getWidgetContactID(r)
|
|
if err != nil {
|
|
app.lo.Error("error getting contact ID from middleware context", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
inbox, err := getWidgetInbox(r)
|
|
if err != nil {
|
|
app.lo.Error("error getting inbox from middleware context", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
// Fetch conversations for the contact and convert to ChatConversation format.
|
|
chatConversations, err := app.conversation.GetContactChatConversations(contactID, inbox.ID)
|
|
if err != nil {
|
|
app.lo.Error("error fetching conversations for contact", "contact_id", contactID, "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
return r.SendEnvelope(chatConversations)
|
|
}
|
|
|
|
// handleChatSendMessage sends a message in a chat conversation
|
|
func handleChatSendMessage(r *fastglue.Request) error {
|
|
var (
|
|
app = r.Context.(*App)
|
|
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
|
|
req = struct {
|
|
Message string `json:"message"`
|
|
}{}
|
|
senderType = cmodels.SenderTypeContact
|
|
)
|
|
|
|
if err := r.Decode(&req, "json"); err != nil {
|
|
app.lo.Error("error unmarshalling chat message request", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
|
|
}
|
|
|
|
if req.Message == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError)
|
|
}
|
|
if len(req.Message) > maxChatMessageLength {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.maxLength", "max", strconv.Itoa(maxChatMessageLength)), nil, envelope.InputError)
|
|
}
|
|
|
|
senderID, conversation, err := getContactConversation(r, conversationUUID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
if err := canReply(r, conversation); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
// Insert incoming message and run post processing hooks.
|
|
message := cmodels.Message{
|
|
ConversationUUID: conversationUUID,
|
|
ConversationID: conversation.ID,
|
|
SenderID: senderID,
|
|
Type: cmodels.MessageIncoming,
|
|
SenderType: senderType,
|
|
Status: cmodels.MessageStatusReceived,
|
|
Content: req.Message,
|
|
ContentType: cmodels.ContentTypeText,
|
|
Private: false,
|
|
}
|
|
if message, err = app.conversation.ProcessIncomingLiveChatMessage(message); err != nil {
|
|
app.lo.Error("error processing incoming message", "conversation_uuid", conversationUUID, "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingMessage"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
return sendChatMessageResponse(app, r, message.UUID)
|
|
}
|
|
|
|
// handleWidgetMediaUpload handles media uploads for the widget.
|
|
func handleWidgetMediaUpload(r *fastglue.Request) error {
|
|
app := r.Context.(*App)
|
|
|
|
form, err := r.RequestCtx.MultipartForm()
|
|
if err != nil {
|
|
app.lo.Error("error parsing form data.", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
// Get conversation UUID from form data
|
|
conversationValues, convOk := form.Value["conversation_uuid"]
|
|
if !convOk || len(conversationValues) == 0 || conversationValues[0] == "" {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.conversation}"), nil, envelope.InputError)
|
|
}
|
|
conversationUUID := conversationValues[0]
|
|
|
|
senderID, conversation, err := getContactConversation(r, conversationUUID)
|
|
if err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
if err := canReply(r, conversation); err != nil {
|
|
return sendErrorEnvelope(r, err)
|
|
}
|
|
|
|
// Make sure file upload is enabled for the inbox.
|
|
config, err := getWidgetConfig(r)
|
|
if err != nil {
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
if !config.Features.FileUpload {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("status.disabledFileUpload"), nil, envelope.InputError)
|
|
}
|
|
|
|
files, ok := form.File["files"]
|
|
if !ok || len(files) == 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("validation.notFoundFile"), nil, envelope.InputError)
|
|
}
|
|
|
|
fileHeader := files[0]
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
app.lo.Error("error reading uploaded file", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Sanitize filename.
|
|
srcFileName := stringutil.SanitizeFilename(fileHeader.Filename)
|
|
srcContentType := fileHeader.Header.Get("Content-Type")
|
|
srcFileSize := fileHeader.Size
|
|
srcExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(srcFileName)), ".")
|
|
|
|
if srcFileSize <= 0 {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileEmpty"), nil, envelope.InputError)
|
|
}
|
|
|
|
// Check file size
|
|
consts := app.consts.Load().(*constants)
|
|
if bytesToMegabytes(srcFileSize) > float64(consts.MaxFileUploadSizeMB) {
|
|
app.lo.Error("error: uploaded file size is larger than max allowed", "size", bytesToMegabytes(srcFileSize), "max_allowed", consts.MaxFileUploadSizeMB)
|
|
return r.SendErrorEnvelope(
|
|
fasthttp.StatusRequestEntityTooLarge,
|
|
app.i18n.Ts("media.fileSizeTooLarge", "size", fmt.Sprintf("%dMB", consts.MaxFileUploadSizeMB)),
|
|
nil,
|
|
envelope.GeneralError,
|
|
)
|
|
}
|
|
|
|
// Make sure the file extension is allowed.
|
|
if !slices.Contains(consts.AllowedUploadFileExtensions, "*") && !slices.Contains(consts.AllowedUploadFileExtensions, srcExt) {
|
|
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("media.fileTypeNotAllowed"), nil, envelope.InputError)
|
|
}
|
|
|
|
fileContent, err := io.ReadAll(file)
|
|
if err != nil {
|
|
app.lo.Error("error reading file content", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
message := cmodels.Message{
|
|
ConversationUUID: conversationUUID,
|
|
ConversationID: conversation.ID,
|
|
SenderID: senderID,
|
|
Type: cmodels.MessageIncoming,
|
|
SenderType: cmodels.SenderTypeContact,
|
|
Status: cmodels.MessageStatusReceived,
|
|
Content: "",
|
|
ContentType: cmodels.ContentTypeText,
|
|
Private: false,
|
|
Attachments: attachment.Attachments{
|
|
{
|
|
Name: srcFileName,
|
|
ContentType: srcContentType,
|
|
Size: int(srcFileSize),
|
|
Content: fileContent,
|
|
Disposition: attachment.DispositionAttachment,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Process the incoming message with attachment.
|
|
if message, err = app.conversation.ProcessIncomingLiveChatMessage(message); err != nil {
|
|
app.lo.Error("error processing incoming message with attachment", "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorSendingMessage"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
return sendChatMessageResponse(app, r, message.UUID)
|
|
}
|
|
|
|
// sendChatMessageResponse fetches an inserted message by UUID, signs attachment and
|
|
// avatar URLs, and sends the formatted ChatMessage response.
|
|
func sendChatMessageResponse(app *App, r *fastglue.Request, messageUUID string) error {
|
|
message, err := app.conversation.GetMessage(messageUUID)
|
|
if err != nil {
|
|
app.lo.Error("error fetching inserted message", "message_uuid", messageUUID, "error", err)
|
|
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
|
|
}
|
|
|
|
app.conversation.SignAvatarURL(&message.Author.AvatarURL)
|
|
|
|
// Strip agent email from widget responses.
|
|
author := message.Author
|
|
author.Email = null.String{}
|
|
|
|
return r.SendEnvelope(cmodels.ChatMessage{
|
|
UUID: message.UUID,
|
|
CreatedAt: message.CreatedAt,
|
|
Content: message.Content,
|
|
TextContent: message.TextContent,
|
|
ConversationUUID: message.ConversationUUID,
|
|
Status: message.Status,
|
|
Author: author,
|
|
Attachments: message.Attachments,
|
|
})
|
|
}
|
|
|
|
// getContactConversation gets the contact ID from middleware, fetches the conversation,
|
|
// and verifies the conversation belongs to the contact.
|
|
func getContactConversation(r *fastglue.Request, conversationUUID string) (int, cmodels.Conversation, error) {
|
|
app := r.Context.(*App)
|
|
|
|
contactID, err := getWidgetContactID(r)
|
|
if err != nil {
|
|
app.lo.Error("error getting contact ID from middleware context", "error", err)
|
|
return 0, cmodels.Conversation{}, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
inbox, err := getWidgetInbox(r)
|
|
if err != nil {
|
|
app.lo.Error("error getting inbox from middleware context", "error", err)
|
|
return 0, cmodels.Conversation{}, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
conversation, err := app.conversation.GetConversation(0, conversationUUID, "")
|
|
if err != nil {
|
|
app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
|
|
return 0, cmodels.Conversation{}, err
|
|
}
|
|
|
|
if conversation.ContactID != contactID || conversation.InboxID != inbox.ID {
|
|
app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID, "session_inbox_id", inbox.ID, "conversation_inbox_id", conversation.InboxID)
|
|
return 0, cmodels.Conversation{}, envelope.NewError(envelope.PermissionError, app.i18n.T("status.deniedPermission"), nil)
|
|
}
|
|
|
|
return contactID, conversation, nil
|
|
}
|
|
|
|
func userTypeLabel(isVisitor bool) string {
|
|
if isVisitor {
|
|
return "visitor"
|
|
}
|
|
return "user"
|
|
}
|
|
|
|
// saveContactAttrsAndCollectConvoAttrs validates and saves contact custom attributes from JWT and form data.
|
|
// Returns conversation custom attributes from the pre-chat form for the caller to apply after conversation creation.
|
|
func saveContactAttrsAndCollectConvoAttrs(app *App, contactID int, claims *Claims, formData map[string]any, config livechat.Config) map[string]any {
|
|
formContactAttrs, formConvoAttrs := validateCustomAttributes(formData, config, app)
|
|
|
|
// Merge JWT contact attributes with form contact attributes (JWT takes precedence).
|
|
var jwtContactAttrs map[string]any
|
|
if claims != nil {
|
|
jwtContactAttrs = claims.ContactCustomAttributes
|
|
}
|
|
mergedContactAttrs := mergeCustomAttributes(jwtContactAttrs, formContactAttrs)
|
|
if len(mergedContactAttrs) > 0 {
|
|
if err := app.user.SaveCustomAttributes(contactID, mergedContactAttrs, false); err != nil {
|
|
app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err)
|
|
}
|
|
}
|
|
|
|
return formConvoAttrs
|
|
}
|
|
|
|
// resolveOrCreateExternalContact finds a contact by external_user_id (syncing changed JWT fields) or creates one.
|
|
func resolveOrCreateExternalContact(app *App, claims Claims) (int, error) {
|
|
user, err := resolveUserFromClaims(app, claims)
|
|
if err != nil {
|
|
if envErr, ok := err.(envelope.Error); !ok || envErr.ErrorType != envelope.NotFoundError {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
// Sync name/email/phone from JWT only if changed.
|
|
if user.ID > 0 && claims.ExternalUserID != "" {
|
|
if user.FirstName != claims.FirstName || user.LastName != claims.LastName || user.Email.String != claims.Email ||
|
|
user.PhoneNumber.String != claims.PhoneNumber || user.PhoneNumberCountryCode.String != claims.PhoneNumberCountryCode {
|
|
if err := app.user.UpdateContactBasicInfo(user.ID, claims.FirstName, claims.LastName, claims.Email, claims.PhoneNumber, claims.PhoneNumberCountryCode); err != nil {
|
|
app.lo.Error("error updating contact basic info", "contact_id", user.ID, "error", err)
|
|
}
|
|
}
|
|
return user.ID, nil
|
|
}
|
|
|
|
// Create contact if not found.
|
|
if claims.ExternalUserID != "" {
|
|
user := umodels.User{
|
|
FirstName: claims.FirstName,
|
|
LastName: claims.LastName,
|
|
Email: null.NewString(claims.Email, claims.Email != ""),
|
|
PhoneNumber: null.NewString(claims.PhoneNumber, claims.PhoneNumber != ""),
|
|
PhoneNumberCountryCode: null.NewString(claims.PhoneNumberCountryCode, claims.PhoneNumberCountryCode != ""),
|
|
ExternalUserID: null.NewString(claims.ExternalUserID, true),
|
|
CustomAttributes: marshalCustomAttributes(claims.ContactCustomAttributes, app),
|
|
}
|
|
if err := app.user.ResolveContact(&user, umodels.ContactSync); err != nil {
|
|
return 0, err
|
|
}
|
|
return user.ID, nil
|
|
}
|
|
|
|
return user.ID, nil
|
|
}
|
|
|
|
// createVisitorContact creates a new visitor contact from form data.
|
|
func createVisitorContact(app *App, formData map[string]any, config livechat.Config, inbox imodels.Inbox) (umodels.User, string, map[string]any, error) {
|
|
// Validate form data and get final name/email/phone for new visitor.
|
|
finalName, finalEmail, finalPhone, finalPhoneCountryCode, err := validateFormData(app, formData, config, nil)
|
|
if err != nil {
|
|
return umodels.User{}, "", nil, err
|
|
}
|
|
|
|
// Process custom attributes from form data, split by applies_to.
|
|
formContactAttrs, formConvoAttrs := validateCustomAttributes(formData, config, app)
|
|
|
|
visitor := umodels.User{
|
|
Email: null.NewString(finalEmail, finalEmail != ""),
|
|
FirstName: finalName,
|
|
PhoneNumber: null.NewString(finalPhone, finalPhone != ""),
|
|
PhoneNumberCountryCode: null.NewString(finalPhoneCountryCode, finalPhoneCountryCode != ""),
|
|
CustomAttributes: marshalCustomAttributes(formContactAttrs, app),
|
|
}
|
|
|
|
if err := app.user.CreateVisitor(&visitor); err != nil {
|
|
app.lo.Error("error creating visitor contact", "error", err)
|
|
return umodels.User{}, "", nil, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
token, err := generateSessionToken(app, visitor.ID, inbox.ID, true, "", defaultSessionTTL)
|
|
if err != nil {
|
|
app.lo.Error("error generating session token for visitor", "error", err)
|
|
return umodels.User{}, "", nil, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
|
|
return visitor, token, formConvoAttrs, nil
|
|
}
|
|
|
|
// checkConversationPermissions checks if the user is allowed to start a conversation based on inbox config.
|
|
// Returns an error if the user is not allowed to start a conversation.
|
|
func checkConversationPermissions(app *App, config livechat.Config, isVisitor bool, contactID, inboxID int) error {
|
|
var allowStartConversation, preventMultipleConversations bool
|
|
if isVisitor {
|
|
allowStartConversation = config.Visitors.AllowStartConversation
|
|
preventMultipleConversations = config.Visitors.PreventMultipleConversations
|
|
} else {
|
|
allowStartConversation = config.Users.AllowStartConversation
|
|
preventMultipleConversations = config.Users.PreventMultipleConversations
|
|
}
|
|
|
|
if !allowStartConversation {
|
|
return envelope.NewError(envelope.InputError, "Not allowed.", nil)
|
|
}
|
|
|
|
if preventMultipleConversations {
|
|
conversations, err := app.conversation.GetContactChatConversations(contactID, inboxID)
|
|
if err != nil {
|
|
app.lo.Error("error fetching "+userTypeLabel(isVisitor)+" conversations", "contact_id", contactID, "error", err)
|
|
return envelope.NewError(envelope.GeneralError, "Error checking existing conversations", nil)
|
|
}
|
|
if len(conversations) > 0 {
|
|
app.lo.Info(userTypeLabel(isVisitor)+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations))
|
|
return envelope.NewError(envelope.PermissionError, "Multiple conversations are not allowed", nil)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildConversationResponseWithBusinessHours builds conversation response with business hours info
|
|
func buildConversationResponseWithBusinessHours(app *App, conversation cmodels.Conversation) (conversationResponseWithBusinessHours, error) {
|
|
widgetResp, err := app.conversation.BuildWidgetConversationResponse(conversation, true)
|
|
if err != nil {
|
|
return conversationResponseWithBusinessHours{}, err
|
|
}
|
|
|
|
resp := conversationResponseWithBusinessHours{
|
|
conversationResp: conversationResp{
|
|
Conversation: widgetResp.Conversation,
|
|
Messages: widgetResp.Messages,
|
|
},
|
|
BusinessHoursID: widgetResp.BusinessHoursID,
|
|
WorkingHoursUTCOffset: widgetResp.WorkingHoursUTCOffset,
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// resolveUserFromClaims resolves the actual user from JWT claims,
|
|
// handling both regular user_id and external_user_id cases.
|
|
func resolveUserFromClaims(app *App, claims Claims) (umodels.User, error) {
|
|
var (
|
|
user umodels.User
|
|
err error
|
|
)
|
|
|
|
switch {
|
|
case claims.UserID > 0:
|
|
user, err = app.user.Get(claims.UserID, "", []string{umodels.UserTypeContact, umodels.UserTypeVisitor})
|
|
case claims.ExternalUserID != "":
|
|
user, err = app.user.GetContactByExternalID(claims.ExternalUserID)
|
|
default:
|
|
return umodels.User{}, errors.New("error fetching user")
|
|
}
|
|
|
|
if err != nil {
|
|
app.lo.Error("error fetching user", "user_id", claims.UserID, "external_user_id", claims.ExternalUserID, "error", err)
|
|
return umodels.User{}, err
|
|
}
|
|
if !user.Enabled {
|
|
return umodels.User{}, envelope.NewError(envelope.PermissionError, "User is disabled", nil)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
// verifyJWT verifies and validates a JWT token with proper signature verification.
|
|
func verifyJWT(tokenString string, secretKey []byte) (*Claims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return secretKey, nil
|
|
}, jwt.WithExpirationRequired())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
|
return claims, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
// verifyStandardJWT verifies a JWT token using inbox secret
|
|
func verifyStandardJWT(jwtToken string, inboxSecret string) (Claims, error) {
|
|
if jwtToken == "" {
|
|
return Claims{}, fmt.Errorf("JWT token is empty")
|
|
}
|
|
|
|
if inboxSecret == "" {
|
|
return Claims{}, fmt.Errorf("inbox `secret` is not configured for JWT verification")
|
|
}
|
|
|
|
claims, err := verifyJWT(jwtToken, []byte(inboxSecret))
|
|
if err != nil {
|
|
return Claims{}, err
|
|
}
|
|
|
|
return *claims, nil
|
|
}
|
|
|
|
// generateSessionToken creates a random session token and stores it in Redis.
|
|
func generateSessionToken(app *App, userID, inboxID int, isVisitor bool, externalUserID string, ttl time.Duration) (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("generating random token: %w", err)
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(b)
|
|
key := widgetSessionPrefix + token
|
|
|
|
ctx := context.Background()
|
|
fields := map[string]any{
|
|
"user_id": strconv.Itoa(userID),
|
|
"inbox_id": strconv.Itoa(inboxID),
|
|
"is_visitor": strconv.FormatBool(isVisitor),
|
|
"external_user_id": externalUserID,
|
|
}
|
|
pipe := app.redis.Pipeline()
|
|
pipe.HSet(ctx, key, fields)
|
|
pipe.Expire(ctx, key, ttl)
|
|
if _, err := pipe.Exec(ctx); err != nil {
|
|
return "", fmt.Errorf("storing session in Redis: %w", err)
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
// loadSession retrieves a session from Redis and refreshes its TTL using the
|
|
// default duration for visitors and the current config duration for contacts.
|
|
func loadSession(app *App, token string, config livechat.Config) (*WidgetSession, error) {
|
|
ctx := context.Background()
|
|
key := widgetSessionPrefix + token
|
|
|
|
data, err := app.redis.HGetAll(ctx, key).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("looking up session: %w", err)
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, fmt.Errorf("session not found or expired")
|
|
}
|
|
|
|
userID, _ := strconv.Atoi(data["user_id"])
|
|
inboxID, _ := strconv.Atoi(data["inbox_id"])
|
|
isVisitor, _ := strconv.ParseBool(data["is_visitor"])
|
|
|
|
ttl := getSessionDuration(config)
|
|
if isVisitor {
|
|
ttl = defaultSessionTTL
|
|
}
|
|
app.redis.Expire(ctx, key, ttl)
|
|
|
|
return &WidgetSession{
|
|
UserID: userID,
|
|
InboxID: inboxID,
|
|
IsVisitor: isVisitor,
|
|
ExternalUserID: data["external_user_id"],
|
|
}, nil
|
|
}
|
|
|
|
// deleteSessionToken removes a session from Redis.
|
|
func deleteSessionToken(app *App, token string) {
|
|
app.redis.Del(context.Background(), widgetSessionPrefix+token)
|
|
}
|
|
|
|
// marshalCustomAttributes marshals custom attributes to JSON, returning "{}" on error or empty input.
|
|
func marshalCustomAttributes(attrs map[string]any, app *App) []byte {
|
|
if len(attrs) == 0 {
|
|
return []byte("{}")
|
|
}
|
|
b, err := json.Marshal(attrs)
|
|
if err != nil {
|
|
app.lo.Error("error marshalling custom attributes", "error", err)
|
|
return []byte("{}")
|
|
}
|
|
return b
|
|
}
|
|
|
|
// mergeCustomAttributes merges JWT and form custom attributes.
|
|
// JWT attributes take precedence as they are server-signed and trusted.
|
|
func mergeCustomAttributes(jwtAttributes, formAttributes map[string]any) map[string]any {
|
|
merged := make(map[string]any)
|
|
maps.Copy(merged, formAttributes)
|
|
maps.Copy(merged, jwtAttributes)
|
|
return merged
|
|
}
|
|
|
|
// validateCustomAttributes validates pre chat form data and splits into contact and conversation attributes based on applies_to.
|
|
func validateCustomAttributes(formData map[string]any, config livechat.Config, app *App) (contactAttrs, conversationAttrs map[string]any) {
|
|
contactAttrs = make(map[string]any)
|
|
conversationAttrs = make(map[string]any)
|
|
|
|
if !config.PreChatForm.Enabled || len(formData) == 0 {
|
|
return contactAttrs, conversationAttrs
|
|
}
|
|
|
|
// Validate total number of form fields
|
|
const maxFormFields = 100
|
|
if len(formData) > maxFormFields {
|
|
app.lo.Warn("form data exceeds maximum allowed fields", "received", len(formData), "max", maxFormFields)
|
|
return contactAttrs, conversationAttrs
|
|
}
|
|
|
|
// Create a map of valid field keys for quick lookup
|
|
validFields := make(map[string]livechat.PreChatFormField)
|
|
phoneCompanionKeys := make(map[string]bool)
|
|
for _, field := range config.PreChatForm.Fields {
|
|
if field.Enabled {
|
|
validFields[field.Key] = field
|
|
if field.Type == fieldTypePhone {
|
|
phoneCompanionKeys[field.Key+phoneCountryCodeSuffix] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process each form data field
|
|
for key, value := range formData {
|
|
// Validate field key length
|
|
const maxKeyLength = 100
|
|
if len(key) > maxKeyLength {
|
|
app.lo.Warn("form field key exceeds maximum length", "key", key, "length", len(key), "max", maxKeyLength)
|
|
continue
|
|
}
|
|
|
|
if phoneCompanionKeys[key] {
|
|
continue
|
|
}
|
|
|
|
// Check if field is valid according to pre-chat form config
|
|
field, exists := validFields[key]
|
|
if !exists {
|
|
app.lo.Warn("form field not found in pre-chat form configuration", "key", key)
|
|
continue
|
|
}
|
|
|
|
// Skip default fields (name, email) - these are handled separately
|
|
if field.IsDefault {
|
|
continue
|
|
}
|
|
|
|
// Only process custom fields that have a custom_attribute_id
|
|
if field.CustomAttributeID == 0 {
|
|
continue
|
|
}
|
|
|
|
// Validate value
|
|
validated := validateAttributeValue(key, value, app)
|
|
if validated == nil {
|
|
continue
|
|
}
|
|
|
|
// Look up the custom attribute definition to determine applies_to
|
|
attr, err := app.customAttribute.Get(field.CustomAttributeID)
|
|
if err != nil {
|
|
app.lo.Warn("custom attribute not found", "custom_attribute_id", field.CustomAttributeID, "error", err)
|
|
continue
|
|
}
|
|
|
|
if attr.AppliesTo == "conversation" {
|
|
conversationAttrs[field.Key] = validated
|
|
} else {
|
|
contactAttrs[field.Key] = validated
|
|
}
|
|
}
|
|
|
|
return contactAttrs, conversationAttrs
|
|
}
|
|
|
|
// validateAttributeValue validates and sanitizes a single attribute value.
|
|
func validateAttributeValue(key string, value any, app *App) any {
|
|
if strValue, ok := value.(string); ok {
|
|
const maxValueLength = 1000
|
|
if len(strValue) > maxValueLength {
|
|
app.lo.Warn("form field value exceeds maximum length", "key", key, "length", len(strValue), "max", maxValueLength)
|
|
return strValue[:maxValueLength]
|
|
}
|
|
return strValue
|
|
}
|
|
|
|
if numValue, ok := value.(float64); ok {
|
|
if math.IsNaN(numValue) || math.IsInf(numValue, 0) {
|
|
app.lo.Warn("form field contains invalid numeric value", "key", key, "value", numValue)
|
|
return nil
|
|
}
|
|
if numValue > 1e12 || numValue < -1e12 {
|
|
app.lo.Warn("form field numeric value out of acceptable range", "key", key, "value", numValue)
|
|
return nil
|
|
}
|
|
return numValue
|
|
}
|
|
|
|
if boolValue, ok := value.(bool); ok {
|
|
return boolValue
|
|
}
|
|
|
|
// Reject all other types (arrays, objects, etc.) to prevent arbitrary data in JSONB.
|
|
app.lo.Warn("form field contains unsupported value type", "key", key)
|
|
return nil
|
|
}
|
|
|
|
// validateFormData returns the final name/email/phone/phone country code to persist from the pre-chat form.
|
|
func validateFormData(app *App, formData map[string]any, config livechat.Config, existingUser *umodels.User) (string, string, string, string, error) {
|
|
var finalName, finalEmail, finalPhone, finalPhoneCountryCode string
|
|
|
|
if !config.PreChatForm.Enabled {
|
|
return finalName, finalEmail, finalPhone, finalPhoneCountryCode, nil
|
|
}
|
|
|
|
var exName, exEmail, exPhone, exPhoneCountryCode string
|
|
if existingUser != nil {
|
|
exName = existingUser.FirstName
|
|
exEmail = existingUser.Email.String
|
|
exPhone = existingUser.PhoneNumber.String
|
|
exPhoneCountryCode = existingUser.PhoneNumberCountryCode.String
|
|
}
|
|
|
|
for _, field := range config.PreChatForm.Fields {
|
|
if !field.Enabled || !field.IsDefault {
|
|
continue
|
|
}
|
|
|
|
switch field.Key {
|
|
case "name":
|
|
finalName = resolveFormField(formData, field.Key, exName)
|
|
if field.Required && finalName == "" {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.name}"), nil)
|
|
}
|
|
if len(finalName) > maxNameLength {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.name}", "max", strconv.Itoa(maxNameLength)), nil)
|
|
}
|
|
|
|
case "email":
|
|
finalEmail = resolveFormField(formData, field.Key, exEmail)
|
|
if field.Required && finalEmail == "" {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.email}"), nil)
|
|
}
|
|
if len(finalEmail) > maxEmailLength {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.email}", "max", strconv.Itoa(maxEmailLength)), nil)
|
|
}
|
|
if finalEmail != "" && !stringutil.ValidEmail(finalEmail) {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.T("validation.invalidEmail"), nil)
|
|
}
|
|
|
|
case fieldTypePhone:
|
|
finalPhone = resolveFormField(formData, field.Key, exPhone)
|
|
finalPhoneCountryCode = resolveFormField(formData, field.Key+phoneCountryCodeSuffix, exPhoneCountryCode)
|
|
if field.Required && (finalPhone == "" || finalPhoneCountryCode == "") {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.phoneNumber}"), nil)
|
|
}
|
|
if len(finalPhone) > maxPhoneNumberLength {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.phoneNumber}", "max", strconv.Itoa(maxPhoneNumberLength)), nil)
|
|
}
|
|
if len(finalPhoneCountryCode) > maxPhoneCountryCodeLength {
|
|
return "", "", "", "", envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.fieldTooLong", "field", "{globals.terms.countryCode}", "max", strconv.Itoa(maxPhoneCountryCodeLength)), nil)
|
|
}
|
|
if finalPhone == "" {
|
|
finalPhoneCountryCode = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
return finalName, finalEmail, finalPhone, finalPhoneCountryCode, nil
|
|
}
|
|
|
|
func resolveFormField(formData map[string]any, key, existing string) string {
|
|
if existing != "" {
|
|
return existing
|
|
}
|
|
if value, ok := formData[key].(string); ok {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// filterPreChatFormFields filters out pre-chat form fields that reference non-existent custom attributes while retaining the default fields
|
|
func filterPreChatFormFields(fields []livechat.PreChatFormField, app *App) ([]livechat.PreChatFormField, map[int]customAttributeWidget) {
|
|
if len(fields) == 0 {
|
|
return fields, nil
|
|
}
|
|
|
|
// Collect custom attribute IDs and enabled fields
|
|
customAttrIDs := make(map[int]bool)
|
|
enabledFields := make([]livechat.PreChatFormField, 0, len(fields))
|
|
|
|
for _, field := range fields {
|
|
if field.Enabled {
|
|
enabledFields = append(enabledFields, field)
|
|
if field.CustomAttributeID > 0 {
|
|
customAttrIDs[field.CustomAttributeID] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch existing custom attributes
|
|
existingCustomAttrs := make(map[int]customAttributeWidget)
|
|
for id := range customAttrIDs {
|
|
attr, err := app.customAttribute.Get(id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
existingCustomAttrs[id] = customAttributeWidget{
|
|
ID: attr.ID,
|
|
Values: attr.Values,
|
|
Name: attr.Name,
|
|
DataType: attr.DataType,
|
|
}
|
|
}
|
|
|
|
// Filter out fields with non-existent custom attributes
|
|
filteredFields := make([]livechat.PreChatFormField, 0, len(enabledFields))
|
|
for _, field := range enabledFields {
|
|
// Keep default fields
|
|
if field.IsDefault {
|
|
filteredFields = append(filteredFields, field)
|
|
continue
|
|
}
|
|
|
|
// Only keep custom fields if their custom attribute exists
|
|
if attr, exists := existingCustomAttrs[field.CustomAttributeID]; exists {
|
|
// Sync label and type from the current custom attribute definition.
|
|
field.Label = attr.Name
|
|
field.Type = attr.DataType
|
|
filteredFields = append(filteredFields, field)
|
|
}
|
|
}
|
|
|
|
return filteredFields, existingCustomAttrs
|
|
}
|
|
|
|
// getSessionDuration returns the configured session TTL for authenticated users.
|
|
// Falls back to defaultSessionTTL if the config value is empty or invalid.
|
|
// Enforces a minimum of 1 hour.
|
|
func getSessionDuration(config livechat.Config) time.Duration {
|
|
if config.SessionDuration == "" {
|
|
return defaultSessionTTL
|
|
}
|
|
d, err := time.ParseDuration(config.SessionDuration)
|
|
if err != nil || d < minSessionTTL {
|
|
return defaultSessionTTL
|
|
}
|
|
return d
|
|
}
|
|
|
|
// canReply checks if the conversation is closed and if the sender is allowed to reply based on inbox settings.
|
|
func canReply(r *fastglue.Request, conversation cmodels.Conversation) error {
|
|
if conversation.Status.String != cmodels.StatusClosed {
|
|
return nil
|
|
}
|
|
app := r.Context.(*App)
|
|
lcConfig, err := getWidgetConfig(r)
|
|
if err != nil {
|
|
return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)
|
|
}
|
|
preventReply := lcConfig.Visitors.PreventReplyToClosedConversation
|
|
if !getWidgetIsVisitor(r) {
|
|
preventReply = lcConfig.Users.PreventReplyToClosedConversation
|
|
}
|
|
if preventReply {
|
|
return envelope.NewError(envelope.PermissionError, app.i18n.T("widget.conversationClosed"), nil)
|
|
}
|
|
return nil
|
|
}
|