package main import ( "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "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/httputil" "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:" widgetSessionTTL = 90 * 24 * time.Hour ) // 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"` 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 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 := validateLiveChatInbox(r) if err != nil { return sendErrorEnvelope(r, err) } 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 := validateLiveChatInbox(r) if err != nil { return sendErrorEnvelope(r, err) } 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 ) 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) } 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 contactID, newSessionToken, conversationAttrs, err = createVisitorContact(app, req.FormData, config, inbox) if err != nil { app.lo.Error("error creating visitor contact", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } } // 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, } } 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 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) } 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 == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "external_user_id"), nil, envelope.InputError) } if claims.Email == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "email"), nil, envelope.InputError) } if claims.FirstName == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "first_name"), nil, envelope.InputError) } // 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) } } // Delete previous session for this user+inbox if one exists. ctx := context.Background() reverseKey := fmt.Sprintf("widget_user:%d:%d", inbox.ID, contactID) if oldToken, err := app.redis.Get(ctx, reverseKey).Result(); err == nil { deleteSessionToken(app, oldToken) } // Generate session token. token, err := generateSessionToken(app, contactID, inbox.ID, false, claims.ExternalUserID) 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) } // Store reverse mapping for cleanup on next exchange. app.redis.Set(ctx, reverseKey, token, widgetSessionTTL) 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, }, }) } // 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{}) 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 == "visitor", "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 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 { var ( 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) } senderID, conversation, err := getContactConversation(r, conversationUUID) if err != nil { return err } // Fetch sender. sender, err := app.user.Get(senderID, "", []string{}) if err != nil { app.lo.Error("error fetching sender user", "sender_id", senderID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } // Check if replies to closed conversations are allowed. if conversation.Status.String == cmodels.StatusClosed { lcConfig, err := getWidgetConfig(r) if err != nil { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } preventReply := lcConfig.Visitors.PreventReplyToClosedConversation if sender.Type != umodels.UserTypeVisitor { preventReply = lcConfig.Users.PreventReplyToClosedConversation } if preventReply { return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("widget.conversationClosed"), nil, envelope.InputError) } } // 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) } // Fetch just inserted message to return. message, err = app.conversation.GetMessage(message.UUID) if err != nil { app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } for i := range message.Attachments { message.Attachments[i].URL = app.media.GetSignedURL(message.Attachments[i].UUID) } app.conversation.SignAvatarURL(&message.Author.AvatarURL) return r.SendEnvelope(cmodels.ChatMessage{ UUID: message.UUID, CreatedAt: message.CreatedAt, Content: message.Content, TextContent: message.TextContent, ConversationUUID: message.ConversationUUID, Status: message.Status, Author: message.Author, Attachments: message.Attachments, }) } // handleWidgetMediaUpload handles media uploads for the widget. func handleWidgetMediaUpload(r *fastglue.Request) error { var ( 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 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)), ".") // 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) } // Read file content into byte slice file.Seek(0, 0) fileContent := make([]byte, srcFileSize) if _, err := file.Read(fileContent); 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) } // Fetch the inserted message to get the media information. insertedMessage, err := app.conversation.GetMessage(message.UUID) if err != nil { app.lo.Error("error fetching inserted message", "message_uuid", message.UUID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } for i := range insertedMessage.Attachments { insertedMessage.Attachments[i].URL = app.media.GetSignedURL(insertedMessage.Attachments[i].UUID) } app.conversation.SignAvatarURL(&insertedMessage.Author.AvatarURL) return r.SendEnvelope(cmodels.ChatMessage{ UUID: insertedMessage.UUID, CreatedAt: insertedMessage.CreatedAt, Content: insertedMessage.Content, TextContent: insertedMessage.TextContent, ConversationUUID: insertedMessage.ConversationUUID, Status: insertedMessage.Status, Author: insertedMessage.Author, Attachments: insertedMessage.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{}, r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } 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{}, sendErrorEnvelope(r, err) } if conversation.ContactID != contactID { app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID) return 0, cmodels.Conversation{}, r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError) } return contactID, conversation, nil } func parseLiveChatConfig(inbox imodels.Inbox) (livechat.Config, error) { var config livechat.Config if err := json.Unmarshal(inbox.Config, &config); err != nil { return livechat.Config{}, fmt.Errorf("parsing live chat config: %w", err) } return config, nil } func userTypeLabel(isVisitor bool) string { if isVisitor { return "visitor" } return "user" } // validateLiveChatInbox validates inbox_id from query params and returns the inbox and parsed config. // Used by public widget endpoints that don't require JWT authentication. func validateLiveChatInbox(r *fastglue.Request) (imodels.Inbox, livechat.Config, error) { app := r.Context.(*App) inboxUUID := string(r.RequestCtx.QueryArgs().Peek("inbox_id")) if inboxUUID == "" { return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) } inbox, err := app.inbox.GetDBRecord(inboxUUID) if err != nil { app.lo.Error("error fetching inbox", "inbox_uuid", inboxUUID, "error", err) return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) } if inbox.Channel != livechat.ChannelLiveChat { return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.InputError, app.i18n.T("validation.notFoundInbox"), nil) } if !inbox.Enabled { return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.InputError, app.i18n.T("status.disabledInbox"), nil) } config, err := parseLiveChatConfig(inbox) if err != nil { app.lo.Error("error parsing live chat config", "error", err) return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.GeneralError, app.i18n.T("validation.invalidInbox"), nil) } // Check if the client's IP is blocked. if len(config.BlockedIPs) > 0 { clientIP := realip.FromRequest(r.RequestCtx) if httputil.IsIPBlocked(clientIP, config.BlockedIPs) { app.lo.Info("client IP blocked for live chat inbox", "client_id", clientIP, "inbox_uuid", inboxUUID) return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.PermissionError, app.i18n.T("widget.ipBlocked"), nil) } } // Validate referer against trusted domains. if len(config.TrustedDomains) > 0 { referer := string(r.RequestCtx.Request.Header.Peek("Referer")) if referer != "" && !httputil.IsOriginTrusted(referer, config.TrustedDomains) { app.lo.Warn("widget request from untrusted referer blocked", "referer", referer, "inbox_uuid", inboxUUID, "trusted_domains", config.TrustedDomains) return imodels.Inbox{}, livechat.Config{}, envelope.NewError(envelope.PermissionError, app.i18n.T("widget.ipBlocked"), nil) } } return inbox, config, nil } // 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 or creates a contact from JWT claims. // It tries: 1) lookup by external_user_id, 2) create new (which internally enriches by email if possible). // On every call it syncs name/email from JWT claims if they differ from stored values, to keep contact info up to date. func resolveOrCreateExternalContact(app *App, claims Claims) (int, error) { user, err := resolveUserFromClaims(app, claims) if err != nil { envErr, ok := err.(envelope.Error) if ok && envErr.ErrorType != envelope.NotFoundError { return 0, err } } // Sync name/email 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 { if err := app.user.UpdateContactBasicInfo(user.ID, claims.FirstName, claims.LastName, claims.Email); 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, true), ExternalUserID: null.NewString(claims.ExternalUserID, true), CustomAttributes: marshalCustomAttributes(claims.ContactCustomAttributes, app), } if err := app.user.CreateContact(&user); err != nil { return 0, err } return user.ID, nil } return user.ID, nil } // createVisitorContact creates a new visitor contact from form data. // Returns the contact ID, a session token, and conversation custom attributes. func createVisitorContact(app *App, formData map[string]any, config livechat.Config, inbox imodels.Inbox) (contactID int, sessionToken string, convoAttrs map[string]any, err error) { // Validate form data and get final name/email for new visitor. finalName, finalEmail, err := validateFormData(formData, config, nil) if err != nil { return 0, "", nil, err } // Process custom attributes from form data, split by applies_to. formContactAttrs, formConvoAttrs := validateCustomAttributes(formData, config, app) convoAttrs = formConvoAttrs visitor := umodels.User{ Email: null.NewString(finalEmail, finalEmail != ""), FirstName: finalName, CustomAttributes: marshalCustomAttributes(formContactAttrs, app), } if err := app.user.CreateVisitor(&visitor); err != nil { app.lo.Error("error creating visitor contact", "error", err) return 0, "", nil, err } token, err := generateSessionToken(app, visitor.ID, inbox.ID, true, "") if err != nil { app.lo.Error("error generating session token for visitor", "error", err) return 0, "", nil, err } return visitor.ID, token, convoAttrs, 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 } // resolveUserIDFromClaims resolves the actual user ID 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{}) case claims.ExternalUserID != "": user, err = app.user.GetByExternalID(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{}, errors.New("error fetching user") } if !user.Enabled { return umodels.User{}, errors.New("user is disabled") } return user, nil } // verifyJWT verifies and validates a JWT token with proper signature verification func verifyJWT(tokenString string, secretKey []byte) (*Claims, error) { // Parse and verify the token token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { // Verify the signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return secretKey, nil }) if err != nil { return nil, err } // Extract claims if token is valid 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) (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, widgetSessionTTL) if _, err := pipe.Exec(ctx); err != nil { return "", fmt.Errorf("storing session in Redis: %w", err) } return token, nil } // lookupSessionToken retrieves a session from Redis and refreshes its TTL. func lookupSessionToken(app *App, token string) (*WidgetSession, error) { ctx := context.Background() key := widgetSessionPrefix + token pipe := app.redis.Pipeline() hgetallCmd := pipe.HGetAll(ctx, key) pipe.Expire(ctx, key, widgetSessionTTL) if _, err := pipe.Exec(ctx); err != nil { return nil, fmt.Errorf("looking up session: %w", err) } data := hgetallCmd.Val() 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"]) 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) for _, field := range config.PreChatForm.Fields { if field.Enabled { validFields[field.Key] = field } } // 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 } // 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 validates form data against pre-chat form configuration // Returns the final name/email to use and any validation errors func validateFormData(formData map[string]any, config livechat.Config, existingUser *umodels.User) (string, string, error) { var finalName, finalEmail string if !config.PreChatForm.Enabled { return finalName, finalEmail, nil } // Process each enabled field in the pre-chat form for _, field := range config.PreChatForm.Fields { if !field.Enabled { continue } switch field.Key { case "name": if value, exists := formData[field.Key]; exists { if nameStr, ok := value.(string); ok { // For existing users, ignore form name if they already have one if existingUser != nil && existingUser.FirstName != "" { finalName = existingUser.FirstName } else { finalName = nameStr } } } // Validate required field if field.Required && finalName == "" { return "", "", fmt.Errorf("name is required") } case "email": if value, exists := formData[field.Key]; exists { if emailStr, ok := value.(string); ok { // For existing users, ignore form email if they already have one if existingUser != nil && existingUser.Email.Valid && existingUser.Email.String != "" { finalEmail = existingUser.Email.String } else { finalEmail = emailStr } } } // Validate required field if field.Required && finalEmail == "" { return "", "", fmt.Errorf("email is required") } // Validate email format if provided if finalEmail != "" && !stringutil.ValidEmail(finalEmail) { return "", "", fmt.Errorf("invalid email format") } } } return finalName, finalEmail, nil } // 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 }