clean up live chat

move last message details in the `meta` JSONB column of conversations
This commit is contained in:
Abhinav Raut
2025-07-06 18:46:54 +05:30
parent 5b6a58fba0
commit 61a70f6b52
41 changed files with 2213 additions and 811 deletions

View File

@@ -1,62 +1,64 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"github.com/abhinavxd/libredesk/internal/attachment"
"github.com/abhinavxd/libredesk/internal/conversation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/image"
"github.com/abhinavxd/libredesk/internal/inbox/channel/livechat"
"github.com/abhinavxd/libredesk/internal/stringutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/valyala/fasthttp"
"github.com/volatiletech/null/v9"
"github.com/zerodha/fastglue"
)
const (
// TODO: Can have a global route that serves media files with a signature and expiry.
// Or use the same existing `/uploads`
chatWidgetMediaURL = "/api/v1/widget/media/%s?signature=%s&expires=%d"
)
type onlyJWT struct {
JWT string `json:"jwt"`
}
// Define JWT claims structure
type Claims struct {
UserID int `json:"user_id,omitempty"`
IsGuest bool `json:"is_guest,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
UserID int `json:"user_id,omitempty"`
IsVisitor bool `json:"is_visitor,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
jwt.RegisteredClaims
}
// Chat widget initialization request
type chatInitReq struct {
onlyJWT
// For guest users
GuestName string `json:"guest_name,omitempty"`
GuestEmail string `json:"guest_email,omitempty"`
Message string `json:"message,omitempty"`
InboxID int `json:"inbox_id"`
VisitorName string `json:"visitor_name,omitempty"`
VisitorEmail string `json:"visitor_email,omitempty"`
Message string `json:"message,omitempty"`
InboxID int `json:"inbox_id"`
}
type conversationResp struct {
Conversation Conversation `json:"conversation"`
Messages []chatMessage `json:"messages"`
}
type Conversation struct {
UUID string `json:"uuid"`
}
type chatMessage struct {
CreatedAt time.Time `json:"created_at"`
UUID string `json:"uuid"`
Content string `json:"content"`
SenderType string `json:"sender_type"`
SenderName string `json:"sender_name"`
ConversationID string `json:"conversation_id"`
Conversation models.ChatConversation `json:"conversation"`
Messages []models.ChatMessage `json:"messages"`
}
type chatMessageReq struct {
@@ -72,7 +74,7 @@ func handleGetChatSettings(r *fastglue.Request) error {
)
if inboxID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Inbox ID is required", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Get inbox configuration
@@ -83,17 +85,17 @@ func handleGetChatSettings(r *fastglue.Request) error {
}
if inbox.Channel != livechat.ChannelLiveChat {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid inbox type for chat", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Inbox is disabled", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Invalid inbox configuration", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(config)
@@ -112,7 +114,7 @@ func handleChatInit(r *fastglue.Request) error {
}
if req.Message == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Message is required", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError)
}
// Get inbox configuration
@@ -124,75 +126,55 @@ func handleChatInit(r *fastglue.Request) error {
// Make sure the inbox is enabled and of the correct type
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Inbox is disabled", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
if inbox.Channel != livechat.ChannelLiveChat {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid inbox type for chat", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Parse inbox config
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Invalid inbox configuration", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
var contactID int
var conversationUUID string
var isGuest bool
var isVisitor bool
// Handle authenticated user
if req.JWT != "" {
claims, err := verifyStandardJWT(req.JWT)
if err != nil {
app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.messages.sessionExpired"), nil, envelope.UnauthorizedError)
}
userID := claims.UserID
isGuest = claims.IsGuest
user := umodels.User{
Email: null.StringFrom(claims.Email),
FirstName: claims.Username,
LastName: "",
}
// Get or Create contact / visitor user.
if !isGuest {
if err = app.user.CreateContact(&user); err != nil {
app.lo.Error("error fetching authenticated user contact", "user_id", userID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusNotFound, "User not found", nil, envelope.NotFoundError)
}
} else {
if err = app.user.CreateVisitor(&user); err != nil {
app.lo.Error("error creating guest contact", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating user, Please try again.", nil, envelope.GeneralError)
}
}
contactID = user.ID
contactID = claims.UserID
isVisitor = claims.IsVisitor
} else {
isGuest = true
isVisitor = true
visitor := umodels.User{
Email: null.NewString(req.GuestEmail, req.GuestEmail != ""),
FirstName: req.GuestName,
Email: null.NewString(req.VisitorEmail, req.VisitorEmail != ""),
FirstName: req.VisitorName,
}
if err := app.user.CreateVisitor(&visitor); err != nil {
app.lo.Error("error creating guest contact", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating user, Please try again.", nil, envelope.GeneralError)
app.lo.Error("error creating visitor contact", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
}
contactID = visitor.ID
// Generate guest JWT
req.JWT, err = generateUserJWT(contactID, isGuest, time.Now().Add(24*time.Hour))
// Generate visitor JWT it has longer expiry as short lived jwts will create new visitors every time.
req.JWT, err = generateUserJWT(contactID, isVisitor, time.Now().Add(87600*time.Hour)) // 10 years
if err != nil {
app.lo.Error("error generating guest JWT", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to generate JWT, Please try again.", nil, envelope.GeneralError)
app.lo.Error("error generating visitor JWT", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorGenerating", "name", "{globals.terms.session}"), nil, envelope.GeneralError)
}
}
app.lo.Info("creating new conversation for user", "user_id", contactID, "inbox_id", req.InboxID)
app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", req.InboxID, "is_visitor", isVisitor)
// Create conversation.
_, conversationUUID, err = app.conversation.CreateConversation(
@@ -205,10 +187,10 @@ func handleChatInit(r *fastglue.Request) error {
)
if err != nil {
app.lo.Error("error creating conversation", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating conversation, Please try again.", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
}
// Send message to the just created conversation as user.
// Insert initial message.
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: contactID,
@@ -220,64 +202,82 @@ func handleChatInit(r *fastglue.Request) error {
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.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
}
app.lo.Error("error inserting initial message", "conversation_uuid", conversationUUID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
}
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.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError)
}
// Build response with conversation and messages.
resp, err := buildConversationResponse(app, conversationUUID, contactID)
resp, err := buildConversationResponse(app, conversation)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating conversation, Please try again.", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(map[string]interface{}{
return r.SendEnvelope(map[string]any{
"conversation": resp.Conversation,
"messages": resp.Messages,
"jwt": req.JWT,
})
}
// buildConversationResponse builds the response for a conversation including its messages
func buildConversationResponse(app *App, conversationUUID string, contactID int) (*conversationResp, error) {
// Fetch last 2000 messages, this should suffice as chats shouldn't have too many messages.
private := false
messages, _, err := app.conversation.GetConversationMessages(conversationUUID, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}, &private, 1, 2000)
// 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)
req = onlyJWT{}
)
if conversationUUID == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.conversation}"), nil, envelope.InputError)
}
if err := r.Decode(&req, "json"); err != nil {
app.lo.Error("error unmarshalling chat update last seen request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Verify JWT.
claims, err := verifyStandardJWT(req.JWT)
if err != nil {
app.lo.Error("error fetching conversation messages", "conversation_uuid", conversationUUID, "error", err)
return nil, fmt.Errorf("failed to fetch conversation messages: %v", err)
app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
contactID := claims.UserID
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Convert to chat message format
chatMessages := make([]chatMessage, len(messages))
nameMap := make(map[int]string)
for i, msg := range messages {
// Get sender name
senderName := nameMap[msg.SenderID]
if msg.SenderType == models.SenderTypeContact {
if senderName == "" {
if contact, err := app.user.GetContact(contactID, ""); err == nil {
senderName = contact.FullName()
nameMap[msg.SenderID] = senderName
}
}
}
chatMessages[i] = chatMessage{
UUID: msg.UUID,
Content: msg.TextContent,
CreatedAt: msg.CreatedAt,
SenderType: msg.SenderType,
SenderName: senderName,
ConversationID: msg.ConversationUUID,
}
// Fetch conversation.
conversation, err := app.conversation.GetConversation(0, conversationUUID)
if err != nil {
app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
return sendErrorEnvelope(r, err)
}
resp := &conversationResp{
Conversation: Conversation{
UUID: conversationUUID,
},
Messages: chatMessages,
// Make sure the conversation belongs to the contact.
if conversation.ContactID != contactID {
app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
return resp, nil
// Update last seen timestamp.
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(map[string]bool{"success": true})
}
// handleChatGetConversation fetches a chat conversation by ID
@@ -299,42 +299,36 @@ func handleChatGetConversation(r *fastglue.Request) error {
}
// Verify JWT.
if chatReq.JWT == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError)
}
claims, err := verifyStandardJWT(chatReq.JWT)
if err != nil {
app.lo.Error("invalid JWT", "jwt", chatReq.JWT, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil,
envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
contactID := claims.UserID
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Fetch conversation details
// Fetch conversation.
conversation, err := app.conversation.GetConversation(0, conversationUUID)
if err != nil {
app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Conversation not found", nil, envelope.NotFoundError)
return sendErrorEnvelope(r, err)
}
// Make sure the conversation belongs to the contact
// Make sure the conversation belongs to the contact.
if conversation.ContactID != contactID {
app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You do not have access to this conversation", nil, envelope.PermissionError)
app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", contactID, "conversation_contact_id", conversation.ContactID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
// Build conversation response with messages
resp, err := buildConversationResponse(app, conversation.UUID, conversation.ContactID)
// Build conversation response with messages and attachments.
resp, err := buildConversationResponse(app, conversation)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to fetch conversation messages", nil, envelope.GeneralError)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(*resp)
return r.SendEnvelope(resp)
}
// handleGetConversations fetches all chat conversations for a widget user
@@ -349,28 +343,21 @@ func handleGetConversations(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
if req.JWT == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError)
}
// Verify JWT.
claims, err := verifyStandardJWT(req.JWT)
if err != nil {
app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
contactID := claims.UserID
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError)
}
conversations, err := app.conversation.GetContactConversations(contactID)
// Fetch conversations for the contact and convert to ChatConversation format.
chatConversations, err := app.conversation.GetContactChatConversations(claims.UserID)
if err != nil {
app.lo.Error("error fetching conversations for contact", "contact_id", contactID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to fetch conversations", nil, envelope.GeneralError)
app.lo.Error("error fetching conversations for contact", "contact_id", claims.UserID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(conversations)
return r.SendEnvelope(chatConversations)
}
// handleChatSendMessage sends a message in a chat conversation
@@ -379,6 +366,8 @@ func handleChatSendMessage(r *fastglue.Request) error {
app = r.Context.(*App)
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
req = chatMessageReq{}
senderType = models.SenderTypeContact
senderID = 0
)
if err := r.Decode(&req, "json"); err != nil {
@@ -386,41 +375,40 @@ func handleChatSendMessage(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
var senderID int
var senderType = models.SenderTypeContact
if req.JWT == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError)
}
if req.Message == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Message content is required", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "{globals.terms.message}"), nil, envelope.InputError)
}
claims, err := verifyStandardJWT(req.JWT)
if err != nil {
app.lo.Error("invalid JWT", "jwt", req.JWT, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
senderID = claims.UserID
if senderID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError)
}
// Fetch conversation to ensure it exists
// Fetch conversation to ensure it exists.
conversation, err := app.conversation.GetConversation(0, conversationUUID)
if err != nil {
app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Conversation not found", nil, envelope.NotFoundError)
return sendErrorEnvelope(r, err)
}
// Make sure the conversation belongs to the sender
// Make sure the conversation belongs to the sender.
if conversation.ContactID != senderID {
app.lo.Error("unauthorized access to conversation", "conversation_uuid", conversationUUID, "contact_id", senderID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You do not have access to this conversation", nil, envelope.PermissionError)
app.lo.Error("access denied: user attempted to access conversation owned by different contact", "conversation_uuid", conversationUUID, "requesting_contact_id", senderID, "conversation_owner_id", conversation.ContactID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
// Create and insert message
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conversation.InboxID)
if err != nil {
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
// Insert message.
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
@@ -434,71 +422,334 @@ func handleChatSendMessage(r *fastglue.Request) error {
if err := app.conversation.InsertMessage(&message); err != nil {
app.lo.Error("error inserting chat message", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to send message", nil, envelope.GeneralError)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
}
return r.SendEnvelope(map[string]bool{"success": true})
}
// handleChatArchiveConversation archives a chat conversation
func handleChatCloseConversation(r *fastglue.Request) error {
// handleWidgetMediaUpload handles media uploads for the widget.
func handleWidgetMediaUpload(r *fastglue.Request) error {
var (
app = r.Context.(*App)
conversationUUID = r.RequestCtx.UserValue("uuid").(string)
onlyJWT = onlyJWT{}
app = r.Context.(*App)
cleanUp = false
senderID = 0
)
if conversationUUID == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "conversation_id is required", nil, envelope.InputError)
}
// Decode
if err := r.Decode(&onlyJWT, "json"); err != nil {
app.lo.Error("error unmarshalling chat close request", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
}
// Verify JWT
if onlyJWT.JWT == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "JWT is required", nil, envelope.InputError)
}
claims, err := verifyStandardJWT(onlyJWT.JWT)
form, err := r.RequestCtx.MultipartForm()
if err != nil {
app.lo.Error("invalid JWT", "jwt", onlyJWT.JWT, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, "Invalid JWT", nil, envelope.InputError)
}
contactID := claims.UserID
if contactID <= 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid user ID in JWT", nil, envelope.InputError)
app.lo.Error("error parsing form data.", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.GeneralError)
}
// Fetch conversation to ensure it exists
// Get JWT token from form data
jwtValues, jwtOk := form.Value["jwt"]
if !jwtOk || len(jwtValues) == 0 || jwtValues[0] == "" {
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
jwtToken := jwtValues[0]
// 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]
// Verify JWT and get user information
claims, err := verifyStandardJWT(jwtToken)
if err != nil {
app.lo.Error("invalid JWT", "jwt", jwtToken, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusUnauthorized, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Set sender ID from JWT claims.
senderID = claims.UserID
// Verify conversation exists and user has access
conversation, err := app.conversation.GetConversation(0, conversationUUID)
if err != nil {
app.lo.Error("error fetching conversation for closing", "conversation_uuid", conversationUUID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Conversation not found", nil, envelope.NotFoundError)
app.lo.Error("error fetching conversation", "conversation_uuid", conversationUUID, "error", err)
return sendErrorEnvelope(r, err)
}
// Make sure the conversation belongs to the contact
if conversation.ContactID != contactID {
app.lo.Error("unauthorized access to conversation for closing", "conversation_uuid", conversationUUID, "contact_id", contactID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, "You do not have access to this conversation", nil, envelope.PermissionError)
// Make sure the conversation belongs to the sender
if conversation.ContactID != senderID {
app.lo.Error("access denied: user attempted to access conversation owned by different contact", "conversation_uuid", conversationUUID, "requesting_contact_id", senderID, "conversation_owner_id", conversation.ContactID)
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.denied", "name", "{globals.terms.permission}"), nil, envelope.PermissionError)
}
contact, err := app.user.GetContact(contactID, "")
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conversation.InboxID)
if err != nil {
app.lo.Error("error fetching contact for closing conversation", "contact_id", contactID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusNotFound, "Contact not found", nil, envelope.NotFoundError)
return sendErrorEnvelope(r, err)
}
if !inbox.Enabled {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"), nil, envelope.InputError)
}
err = app.conversation.UpdateConversationStatus(conversationUUID, 0, models.StatusClosed, "", contact)
// Make sure file upload is enabled for the inbox.
var config livechat.Config
if err := json.Unmarshal(inbox.Config, &config); err != nil {
app.lo.Error("error parsing live chat config", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"), nil, envelope.GeneralError)
}
if !config.Features.FileUpload {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.fileUpload}"), nil, envelope.InputError)
}
files, ok := form.File["files"]
if !ok || len(files) == 0 {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.file}"), nil, envelope.InputError)
}
fileHeader := files[0]
file, err := fileHeader.Open()
if err != nil {
app.lo.Error("error archiving chat conversation", "conversation_uuid", conversationUUID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Failed to archive conversation", nil, envelope.GeneralError)
app.lo.Error("error reading uploaded file", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorReading", "name", "{globals.terms.file}"), 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,
)
}
return r.SendEnvelope(map[string]bool{"success": true})
// 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)
}
// Delete files on any error.
var uuid = uuid.New()
thumbName := thumbPrefix + uuid.String()
defer func() {
if cleanUp {
app.media.Delete(uuid.String())
app.media.Delete(thumbName)
}
}()
// Generate and upload thumbnail and store image dimensions in the media meta.
var meta = []byte("{}")
if slices.Contains(image.Exts, srcExt) {
file.Seek(0, 0)
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
if err != nil {
app.lo.Error("error creating thumb image", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.thumbnail}"), nil, envelope.GeneralError)
}
thumbName, err = app.media.Upload(thumbName, srcContentType, thumbFile)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Store image dimensions in media meta, storing dimensions for image previews in future.
file.Seek(0, 0)
width, height, err := image.GetDimensions(file)
if err != nil {
cleanUp = true
app.lo.Error("error getting image dimensions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorUploading", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
}
meta, _ = json.Marshal(map[string]any{
"width": width,
"height": height,
})
}
file.Seek(0, 0)
_, err = app.media.Upload(uuid.String(), srcContentType, file)
if err != nil {
cleanUp = true
app.lo.Error("error uploading file", "error", err)
return sendErrorEnvelope(r, err)
}
// Insert message with empty content and after insert link the media to the message.
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: senderID,
Type: models.MessageIncoming,
SenderType: models.SenderTypeContact,
Status: models.MessageStatusReceived,
Content: "",
ContentType: models.ContentTypeText,
Private: false,
}
if err := app.conversation.InsertMessage(&message); err != nil {
cleanUp = true
app.lo.Error("error inserting message", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorSending", "name", "{globals.terms.message}"), nil, envelope.GeneralError)
}
// Insert media linked to the just inserted message.
media, err := app.media.Insert(null.StringFrom(attachment.DispositionAttachment), srcFileName, srcContentType, "" /**content_id**/, null.NewString("messages", true), uuid.String(), null.NewInt(message.ID, true), int(srcFileSize), meta)
if err != nil {
cleanUp = true
app.lo.Error("error inserting metadata into database", "error", err)
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(media)
}
// handleWidgetServeMedia serves media files for the widget
func handleWidgetServeMedia(r *fastglue.Request) error {
var (
app = r.Context.(*App)
uuid = r.RequestCtx.UserValue("uuid").(string)
signature = string(r.RequestCtx.QueryArgs().Peek("signature"))
expiresStr = string(r.RequestCtx.QueryArgs().Peek("expires"))
)
if uuid == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "uuid"), nil, envelope.InputError)
}
if signature == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Signature missing", nil, envelope.InputError)
}
if expiresStr == "" {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Expiry missing", nil, envelope.InputError)
}
expires, err := strconv.ParseInt(expiresStr, 10, 64)
if err != nil {
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid expiry", nil, envelope.InputError)
}
// Verify signature and expiration.
expiresAt := time.Unix(expires, 0)
if !VerifySignedURL(uuid, signature, expiresAt, getJWTSecret()) {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("globals.terms.unAuthorized"), nil, envelope.UnauthorizedError)
}
// Get media DB record.
_, err = app.media.Get(0, uuid)
if err != nil {
return sendErrorEnvelope(r, err)
}
consts := app.consts.Load().(*constants)
switch consts.UploadProvider {
case "fs":
fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid))
case "s3":
r.RequestCtx.Redirect(app.media.GetURL(uuid), http.StatusFound)
}
return nil
}
// buildConversationResponse builds the response for a conversation including its messages
func buildConversationResponse(app *App, conversation models.Conversation) (conversationResp, error) {
var resp = conversationResp{}
// Fetch last 1000 messages, this should suffice as chats shouldn't have too many messages.
private := false
messages, _, err := app.conversation.GetConversationMessages(conversation.UUID, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}, &private, 1, 1000)
if err != nil {
app.lo.Error("error fetching conversation messages", "conversation_uuid", conversation.UUID, "error", err)
return resp, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.message}"), nil)
}
// Convert to chat message format, Generate signed widget URL for all attachments - expires in 1 hour.
chatMessages := make([]models.ChatMessage, len(messages))
for i, msg := range messages {
attachments := msg.Attachments
for j := range attachments {
expiresAt := time.Now().Add(1 * time.Hour)
signature := GenerateSignedURL(attachments[j].UUID, expiresAt, getJWTSecret())
attachments[j].URL = fmt.Sprintf(chatWidgetMediaURL, attachments[j].UUID, signature, expiresAt.Unix())
}
chatMessages[i] = models.ChatMessage{
UUID: msg.UUID,
Content: msg.Content,
CreatedAt: msg.CreatedAt,
SenderType: msg.SenderType,
ConversationID: msg.ConversationUUID,
Attachments: attachments,
}
}
var (
assignee umodels.User
)
if conversation.AssignedUserID.Int > 0 {
assignee, err = app.user.GetAgent(conversation.AssignedUserID.Int, "")
if err != nil {
app.lo.Error("error fetching conversation assignee", "conversation_uuid", conversation.UUID, "error", err)
}
// Convert assignee avatar URL to widget format if set.
// TODO: Instead of this hardcoded URL, make it a central handler.
if assignee.AvatarURL.Valid && assignee.AvatarURL.String != "" {
avatarPath := assignee.AvatarURL.String
if strings.HasPrefix(avatarPath, "/uploads/") {
avatarUUID := strings.TrimPrefix(avatarPath, "/uploads/")
// Generate signed URL for avatar with 1 hour expiry
expiresAt := time.Now().Add(1 * time.Hour)
signature := GenerateSignedURL(avatarUUID, expiresAt, getJWTSecret())
assignee.AvatarURL = null.StringFrom(fmt.Sprintf(chatWidgetMediaURL, avatarUUID, signature, expiresAt.Unix()))
}
}
}
resp = conversationResp{
Conversation: models.ChatConversation{
UUID: conversation.UUID,
Status: conversation.Status.String,
Assignee: assignee,
},
Messages: chatMessages,
}
return resp, nil
}
// GenerateSignedURL generates a signature for media access with expiration
func GenerateSignedURL(uuid string, expiresAt time.Time, secret []byte) string {
exp := expiresAt.Unix()
payload := fmt.Sprintf("%s:%d", uuid, exp)
sig := hmacSha256(payload, secret)
return sig
}
// VerifySignedURL verifies a signed URL with expiration
func VerifySignedURL(uuid, signature string, expiresAt time.Time, secret []byte) bool {
// Check if expired
if time.Now().After(expiresAt) {
return false
}
// Generate expected signature
expectedSignature := GenerateSignedURL(uuid, expiresAt, secret)
// Compare signatures using constant time comparison to prevent timing attacks
return hmac.Equal([]byte(signature), []byte(expectedSignature))
}
// hmacSha256 generates HMAC-SHA256 hash
func hmacSha256(data string, secret []byte) string {
h := hmac.New(sha256.New, secret)
h.Write([]byte(data))
return hex.EncodeToString(h.Sum(nil))
}
// verifyJWT verifies and validates a JWT token with proper signature verification
@@ -545,10 +796,10 @@ func getJWTSecret() []byte {
}
// generateUserJWT generates a JWT token for a user
func generateUserJWT(userID int, isGuest bool, expirationTime time.Time) (string, error) {
func generateUserJWT(userID int, isVisitor bool, expirationTime time.Time) (string, error) {
claims := &Claims{
UserID: userID,
IsGuest: isGuest,
UserID: userID,
IsVisitor: isVisitor,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
IssuedAt: jwt.NewNumericDate(time.Now()),