mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
feat(widget): merge visitor to contact when new JWT is available i.e. when user logs into a an external system.
- Prevent file uploads on initial message, user send a message first to enable attachments. - fix thumbs not loading with signature and expiry
This commit is contained in:
+202
-166
@@ -67,50 +67,9 @@ type conversationResponseWithBusinessHours struct {
|
||||
WorkingHoursUTCOffset *int `json:"working_hours_utc_offset,omitempty"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
|
||||
|
||||
if inboxID <= 0 {
|
||||
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
|
||||
fasthttp.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"),
|
||||
nil, envelope.InputError)
|
||||
}
|
||||
|
||||
inbox, err := app.inbox.GetDBRecord(inboxID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
|
||||
return imodels.Inbox{}, livechat.Config{}, sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if inbox.Channel != livechat.ChannelLiveChat {
|
||||
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
|
||||
fasthttp.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"),
|
||||
nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if !inbox.Enabled {
|
||||
return imodels.Inbox{}, livechat.Config{}, 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 imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
|
||||
fasthttp.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"),
|
||||
nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return inbox, config, nil
|
||||
}
|
||||
// -----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// handleGetChatLauncherSettings returns the live chat launcher settings for the widget
|
||||
func handleGetChatLauncherSettings(r *fastglue.Request) error {
|
||||
@@ -206,12 +165,12 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
contactID int
|
||||
conversationUUID string
|
||||
isVisitor bool
|
||||
config livechat.Config
|
||||
newJWT string
|
||||
)
|
||||
|
||||
// Parse inbox config
|
||||
if err := json.Unmarshal(inbox.Config, &config); err != nil {
|
||||
config, err := parseLiveChatConfig(inbox)
|
||||
if 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)
|
||||
}
|
||||
@@ -230,53 +189,13 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// User doesn't exist, create new contact
|
||||
firstName := claims.FirstName
|
||||
lastName := claims.LastName
|
||||
email := claims.Email
|
||||
|
||||
// Validate custom attribute
|
||||
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
|
||||
|
||||
// Merge JWT and form custom attributes (form takes precedence)
|
||||
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
|
||||
|
||||
// Marshal custom attributes
|
||||
customAttribJSON, err := json.Marshal(mergedAttributes)
|
||||
contactID, err = createExternalUser(app, *claims, req.FormData, config)
|
||||
if err != nil {
|
||||
app.lo.Error("error marshalling custom attributes", "error", err)
|
||||
customAttribJSON = []byte("{}")
|
||||
}
|
||||
|
||||
// Create new contact with external user ID.
|
||||
var user = umodels.User{
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Email: null.NewString(email, email != ""),
|
||||
ExternalUserID: null.NewString(claims.ExternalUserID, claims.ExternalUserID != ""),
|
||||
CustomAttributes: customAttribJSON,
|
||||
}
|
||||
err = app.user.CreateContact(&user)
|
||||
if err != nil {
|
||||
app.lo.Error("error creating contact with external ID", "external_user_id", claims.ExternalUserID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
contactID = user.ID
|
||||
} else {
|
||||
// User exists, update custom attributes from both JWT and form
|
||||
// Don't override existing name and email.
|
||||
|
||||
// Validate custom attribute
|
||||
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
|
||||
|
||||
// Merge JWT and form custom attributes (form takes precedence)
|
||||
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
|
||||
|
||||
if len(mergedAttributes) > 0 {
|
||||
if err := app.user.SaveCustomAttributes(user.ID, mergedAttributes, false); err != nil {
|
||||
app.lo.Error("error updating contact custom attributes", "contact_id", user.ID, "error", err)
|
||||
// Don't fail the request for custom attributes update failure
|
||||
}
|
||||
}
|
||||
processCustomAttributesForUser(app, user.ID, claims, req.FormData, config)
|
||||
contactID = user.ID
|
||||
}
|
||||
isVisitor = false
|
||||
@@ -289,96 +208,30 @@ func handleChatInit(r *fastglue.Request) error {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.user}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
// Validate custom attribute
|
||||
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
|
||||
|
||||
// Merge JWT and form custom attributes (form takes precedence)
|
||||
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
|
||||
|
||||
// Update custom attributes from both JWT and form
|
||||
if len(mergedAttributes) > 0 {
|
||||
if err := app.user.SaveCustomAttributes(contactID, mergedAttributes, false); err != nil {
|
||||
app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err)
|
||||
// Don't fail the request for custom attributes update failure
|
||||
}
|
||||
}
|
||||
processCustomAttributesForUser(app, contactID, claims, req.FormData, config)
|
||||
}
|
||||
} else {
|
||||
// Visitor user not authenticated, create a new visitor contact.
|
||||
isVisitor = true
|
||||
|
||||
// Validate form data and get final name/email for new visitor
|
||||
finalName, finalEmail, err := validateFormData(req.FormData, config, nil)
|
||||
if err != nil {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, err.Error(), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
// Process custom attributes from form data
|
||||
formCustomAttributes := validateCustomAttributes(req.FormData, config, app)
|
||||
|
||||
// Marshal custom attributes for storage
|
||||
var customAttribJSON []byte
|
||||
if len(formCustomAttributes) > 0 {
|
||||
customAttribJSON, err = json.Marshal(formCustomAttributes)
|
||||
if err != nil {
|
||||
app.lo.Error("error marshalling form custom attributes", "error", err)
|
||||
customAttribJSON = []byte("{}")
|
||||
var visitorErr error
|
||||
contactID, newJWT, visitorErr = createVisitorContact(app, req.FormData, config, inbox)
|
||||
if visitorErr != nil {
|
||||
// Check if it's a validation error (from validateFormData)
|
||||
if strings.Contains(visitorErr.Error(), "required") || strings.Contains(visitorErr.Error(), "invalid") {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, visitorErr.Error(), nil, envelope.InputError)
|
||||
}
|
||||
} else {
|
||||
customAttribJSON = []byte("{}")
|
||||
}
|
||||
|
||||
visitor := umodels.User{
|
||||
Email: null.NewString(finalEmail, finalEmail != ""),
|
||||
FirstName: finalName,
|
||||
CustomAttributes: customAttribJSON,
|
||||
}
|
||||
|
||||
if err := app.user.CreateVisitor(&visitor); err != nil {
|
||||
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
|
||||
secretToUse := []byte(inbox.Secret.String)
|
||||
newJWT, err = generateUserJWTWithSecret(contactID, isVisitor, time.Now().Add(87600*time.Hour), secretToUse) // 10 years
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Check conversation permissions based on user type.
|
||||
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 r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
if preventMultipleConversations {
|
||||
conversations, err := app.conversation.GetContactChatConversations(contactID, inboxID)
|
||||
if err != nil {
|
||||
userType := "visitor"
|
||||
if !isVisitor {
|
||||
userType = "user"
|
||||
}
|
||||
app.lo.Error("error fetching "+userType+" conversations", "contact_id", contactID, "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError)
|
||||
}
|
||||
if len(conversations) > 0 {
|
||||
userType := "visitor"
|
||||
if !isVisitor {
|
||||
userType = "user"
|
||||
}
|
||||
app.lo.Info(userType+" attempted to start new conversation but already has one", "contact_id", contactID, "conversations_count", len(conversations))
|
||||
if err := checkConversationPermissions(app, config, isVisitor, contactID, inboxID); err != nil {
|
||||
if strings.Contains(err.Error(), "not allowed") || strings.Contains(err.Error(), "multiple conversations") {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.Ts("globals.messages.notAllowed", "name", ""), nil, envelope.PermissionError)
|
||||
}
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.conversation}"), nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
app.lo.Info("creating new live chat conversation for user", "user_id", contactID, "inbox_id", inboxID, "is_visitor", isVisitor)
|
||||
@@ -716,8 +569,8 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Make sure file upload is enabled for the inbox.
|
||||
var config livechat.Config
|
||||
if err := json.Unmarshal(inbox.Config, &config); err != nil {
|
||||
config, err := parseLiveChatConfig(inbox)
|
||||
if 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)
|
||||
}
|
||||
@@ -807,6 +660,189 @@ func handleWidgetMediaUpload(r *fastglue.Request) error {
|
||||
return r.SendEnvelope(insertedMessage)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Helper functions
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// parseLiveChatConfig parses the livechat.Config from an inbox's Config JSON.
|
||||
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("error parsing live chat config: %w", err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// userTypeLabel returns "visitor" or "user" based on the isVisitor flag.
|
||||
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)
|
||||
inboxID := r.RequestCtx.QueryArgs().GetUintOrZero("inbox_id")
|
||||
|
||||
if inboxID <= 0 {
|
||||
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
|
||||
fasthttp.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.required", "name", "{globals.terms.inbox}"),
|
||||
nil, envelope.InputError)
|
||||
}
|
||||
|
||||
inbox, err := app.inbox.GetDBRecord(inboxID)
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching inbox", "inbox_id", inboxID, "error", err)
|
||||
return imodels.Inbox{}, livechat.Config{}, sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if inbox.Channel != livechat.ChannelLiveChat {
|
||||
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
|
||||
fasthttp.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.inbox}"),
|
||||
nil, envelope.InputError)
|
||||
}
|
||||
|
||||
if !inbox.Enabled {
|
||||
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
|
||||
fasthttp.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.disabled", "name", "{globals.terms.inbox}"),
|
||||
nil, envelope.InputError)
|
||||
}
|
||||
|
||||
config, err := parseLiveChatConfig(inbox)
|
||||
if err != nil {
|
||||
app.lo.Error("error parsing live chat config", "error", err)
|
||||
return imodels.Inbox{}, livechat.Config{}, r.SendErrorEnvelope(
|
||||
fasthttp.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.invalid", "name", "{globals.terms.inbox}"),
|
||||
nil, envelope.GeneralError)
|
||||
}
|
||||
|
||||
return inbox, config, nil
|
||||
}
|
||||
|
||||
// processCustomAttributesForUser validates and saves custom attributes from JWT and form data for an existing user.
|
||||
func processCustomAttributesForUser(app *App, contactID int, claims *Claims, formData map[string]any, config livechat.Config) {
|
||||
formCustomAttributes := validateCustomAttributes(formData, config, app)
|
||||
var jwtAttribs map[string]any
|
||||
if claims != nil {
|
||||
jwtAttribs = claims.CustomAttributes
|
||||
}
|
||||
mergedAttributes := mergeCustomAttributes(jwtAttribs, formCustomAttributes)
|
||||
|
||||
if len(mergedAttributes) > 0 {
|
||||
if err := app.user.SaveCustomAttributes(contactID, mergedAttributes, false); err != nil {
|
||||
app.lo.Error("error updating contact custom attributes", "contact_id", contactID, "error", err)
|
||||
// Don't fail the request for custom attributes update failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createExternalUser creates a new contact from JWT claims with external user ID.
|
||||
// Returns the new contact ID.
|
||||
func createExternalUser(app *App, claims Claims, formData map[string]any, config livechat.Config) (int, error) {
|
||||
formCustomAttributes := validateCustomAttributes(formData, config, app)
|
||||
mergedAttributes := mergeCustomAttributes(claims.CustomAttributes, formCustomAttributes)
|
||||
|
||||
customAttribJSON, err := json.Marshal(mergedAttributes)
|
||||
if err != nil {
|
||||
app.lo.Error("error marshalling custom attributes", "error", err)
|
||||
customAttribJSON = []byte("{}")
|
||||
}
|
||||
|
||||
user := umodels.User{
|
||||
FirstName: claims.FirstName,
|
||||
LastName: claims.LastName,
|
||||
Email: null.NewString(claims.Email, claims.Email != ""),
|
||||
ExternalUserID: null.NewString(claims.ExternalUserID, claims.ExternalUserID != ""),
|
||||
CustomAttributes: customAttribJSON,
|
||||
}
|
||||
if err := app.user.CreateContact(&user); err != nil {
|
||||
app.lo.Error("error creating contact with external ID", "external_user_id", claims.ExternalUserID, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
return user.ID, nil
|
||||
}
|
||||
|
||||
// createVisitorContact creates a new visitor contact from form data.
|
||||
// Returns the contact ID and a new JWT for the visitor.
|
||||
func createVisitorContact(app *App, formData map[string]any, config livechat.Config, inbox imodels.Inbox) (contactID int, jwt string, 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, "", err
|
||||
}
|
||||
|
||||
// Process custom attributes from form data
|
||||
formCustomAttributes := validateCustomAttributes(formData, config, app)
|
||||
|
||||
var customAttribJSON []byte
|
||||
if len(formCustomAttributes) > 0 {
|
||||
customAttribJSON, err = json.Marshal(formCustomAttributes)
|
||||
if err != nil {
|
||||
app.lo.Error("error marshalling form custom attributes", "error", err)
|
||||
customAttribJSON = []byte("{}")
|
||||
}
|
||||
} else {
|
||||
customAttribJSON = []byte("{}")
|
||||
}
|
||||
|
||||
visitor := umodels.User{
|
||||
Email: null.NewString(finalEmail, finalEmail != ""),
|
||||
FirstName: finalName,
|
||||
CustomAttributes: customAttribJSON,
|
||||
}
|
||||
|
||||
if err := app.user.CreateVisitor(&visitor); err != nil {
|
||||
app.lo.Error("error creating visitor contact", "error", err)
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
newJWT, err := generateUserJWTWithSecret(visitor.ID, true, time.Now().Add(87600*time.Hour), []byte(inbox.Secret.String)) // 10 years
|
||||
if err != nil {
|
||||
app.lo.Error("error generating visitor JWT", "error", err)
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
return visitor.ID, newJWT, 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 fmt.Errorf("not allowed to start conversation")
|
||||
}
|
||||
|
||||
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 fmt.Errorf("error checking existing conversations: %w", err)
|
||||
}
|
||||
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 fmt.Errorf("multiple conversations not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
+5
-1
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
"github.com/abhinavxd/libredesk/internal/image"
|
||||
"github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/zerodha/fastglue"
|
||||
@@ -268,8 +269,11 @@ func authOrSignedURL(handler fastglue.FastRequestHandler) fastglue.FastRequestHa
|
||||
// Get the UUID from the route.
|
||||
uuid := r.RequestCtx.UserValue("uuid").(string)
|
||||
|
||||
// Strip thumb prefix for signature validation (thumbnails use the same signature as the original).
|
||||
signatureUUID := strings.TrimPrefix(uuid, image.ThumbPrefix)
|
||||
|
||||
// Validate signature.
|
||||
if !validator(uuid, sig, exp) {
|
||||
if !validator(signatureUUID, sig, exp) {
|
||||
return r.SendErrorEnvelope(http.StatusForbidden,
|
||||
app.i18n.T("media.invalidOrExpiredURL"), nil, envelope.PermissionError)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ const (
|
||||
ctxWidgetContactID = "widget_contact_id"
|
||||
ctxWidgetInbox = "widget_inbox"
|
||||
|
||||
// Header sent in every widget request to identify the inbox
|
||||
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
|
||||
hdrWidgetInboxID = "X-Libredesk-Inbox-ID"
|
||||
hdrWidgetVisitorJWT = "X-Libredesk-Visitor-JWT"
|
||||
hdrClearVisitorJWT = "X-Libredesk-Clear-Visitor"
|
||||
)
|
||||
|
||||
// widgetAuth middleware authenticates widget requests using JWT and inbox validation.
|
||||
@@ -28,9 +29,7 @@ const (
|
||||
// For /conversations/init without JWT, it allows visitor creation while still validating inbox.
|
||||
func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) error {
|
||||
return func(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
)
|
||||
app := r.Context.(*App)
|
||||
|
||||
// Always extract and validate inbox_id from custom header
|
||||
inboxIDHeader := string(r.RequestCtx.Request.Header.Peek(hdrWidgetInboxID))
|
||||
@@ -98,6 +97,23 @@ func widgetAuth(next func(*fastglue.Request) error) func(*fastglue.Request) erro
|
||||
r.RequestCtx.SetUserValue(ctxWidgetClaims, claims)
|
||||
r.RequestCtx.SetUserValue(ctxWidgetContactID, contactID)
|
||||
|
||||
// Merge visitor to contact if visitor JWT is provided.
|
||||
visitorJWT := string(r.RequestCtx.Request.Header.Peek(hdrWidgetVisitorJWT))
|
||||
if visitorJWT != "" && claims.ExternalUserID != "" && contactID > 0 {
|
||||
visitorClaims, err := verifyStandardJWT(visitorJWT, inbox.Secret.String)
|
||||
if err == nil && visitorClaims.IsVisitor && visitorClaims.UserID > 0 {
|
||||
visitorID := visitorClaims.UserID
|
||||
if visitorID != contactID {
|
||||
if err := app.user.MergeVisitorToContact(visitorID, contactID); err != nil {
|
||||
app.lo.Error("error merging visitor to contact", "visitor_id", visitorID, "contact_id", contactID, "error", err)
|
||||
} else {
|
||||
app.lo.Info("merged visitor to contact", "visitor_id", visitorID, "contact_id", contactID)
|
||||
r.RequestCtx.Response.Header.Set(hdrClearVisitorJWT, "true")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next(r)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-17
@@ -247,33 +247,18 @@ func handleWidgetTyping(app *App, msg *WidgetMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret
|
||||
// validateWidgetMessageJWT validates the incoming widget message JWT using inbox secret.
|
||||
func validateWidgetMessageJWT(app *App, jwtToken string, inboxID int) (Claims, error) {
|
||||
if jwtToken == "" {
|
||||
return Claims{}, fmt.Errorf("JWT token is empty")
|
||||
}
|
||||
|
||||
if inboxID <= 0 {
|
||||
return Claims{}, fmt.Errorf("inbox ID is required for JWT validation")
|
||||
}
|
||||
|
||||
// Get inbox to retrieve secret for JWT verification
|
||||
inbox, err := app.inbox.GetDBRecord(inboxID)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("inbox not found: %w", err)
|
||||
}
|
||||
|
||||
if !inbox.Secret.Valid {
|
||||
return Claims{}, fmt.Errorf("inbox secret not configured for JWT verification")
|
||||
}
|
||||
|
||||
// Use the existing verifyStandardJWT function which properly validates with inbox secret
|
||||
claims, err := verifyStandardJWT(jwtToken, inbox.Secret.String)
|
||||
if err != nil {
|
||||
return Claims{}, fmt.Errorf("JWT validation failed: %w", err)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
return verifyStandardJWT(jwtToken, inbox.Secret.String)
|
||||
}
|
||||
|
||||
// sendWidgetError sends an error message to the widget client
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { parseJWT } from '@shared-ui/utils/string'
|
||||
|
||||
const VISITOR_JWT_KEY = 'libredesk_visitor_jwt'
|
||||
|
||||
function getInboxIDFromQuery () {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -6,6 +9,35 @@ function getInboxIDFromQuery () {
|
||||
return inboxId ? parseInt(inboxId, 10) : null
|
||||
}
|
||||
|
||||
export function setVisitorJWT (jwt) {
|
||||
localStorage.setItem(VISITOR_JWT_KEY, jwt)
|
||||
}
|
||||
|
||||
export function clearVisitorJWT () {
|
||||
localStorage.removeItem(VISITOR_JWT_KEY)
|
||||
}
|
||||
|
||||
export function getVisitorJWT () {
|
||||
return localStorage.getItem(VISITOR_JWT_KEY)
|
||||
}
|
||||
|
||||
// Returns visitor JWT if current user is authenticated (for merge).
|
||||
function getVisitorJWTForMerge (sessionToken) {
|
||||
const visitorJWT = getVisitorJWT()
|
||||
if (!visitorJWT || !sessionToken) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const claims = parseJWT(sessionToken)
|
||||
if (claims && !claims.is_visitor && claims.external_user_id) {
|
||||
return visitorJWT
|
||||
}
|
||||
} catch {
|
||||
// Ignore JWT parse errors
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
timeout: 10000,
|
||||
responseType: 'json'
|
||||
@@ -21,21 +53,33 @@ http.interceptors.request.use((request) => {
|
||||
if (request.url && request.url.includes('/api/v1/widget/')) {
|
||||
const libredeskSession = localStorage.getItem('libredesk_session')
|
||||
const inboxId = getInboxIDFromQuery()
|
||||
|
||||
|
||||
// Add JWT to Authorization header
|
||||
if (libredeskSession) {
|
||||
request.headers['Authorization'] = `Bearer ${libredeskSession}`
|
||||
}
|
||||
|
||||
|
||||
// Add inbox ID to custom header
|
||||
if (inboxId) {
|
||||
request.headers['X-Libredesk-Inbox-ID'] = inboxId.toString()
|
||||
}
|
||||
|
||||
const visitorJWTForMerge = getVisitorJWTForMerge(libredeskSession)
|
||||
if (visitorJWTForMerge) {
|
||||
request.headers['X-Libredesk-Visitor-JWT'] = visitorJWTForMerge
|
||||
}
|
||||
}
|
||||
|
||||
return request
|
||||
})
|
||||
|
||||
http.interceptors.response.use((response) => {
|
||||
if (response.headers['x-libredesk-clear-visitor']) {
|
||||
clearVisitorJWT()
|
||||
}
|
||||
return response
|
||||
})
|
||||
|
||||
const getWidgetSettings = (inboxID) => http.get('/api/v1/widget/chat/settings', {
|
||||
params: { inbox_id: inboxID }
|
||||
})
|
||||
@@ -47,7 +91,7 @@ const sendChatMessage = (uuid, data) => http.post(`/api/v1/widget/chat/conversat
|
||||
const closeChatConversation = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/close`)
|
||||
const uploadMedia = (conversationUUID, files) => {
|
||||
const formData = new FormData()
|
||||
|
||||
|
||||
// Only add conversation UUID to form data now
|
||||
formData.append('conversation_uuid', conversationUUID)
|
||||
|
||||
@@ -72,9 +116,19 @@ const uploadMedia = (conversationUUID, files) => {
|
||||
headers['X-Libredesk-Inbox-ID'] = inboxId.toString()
|
||||
}
|
||||
|
||||
const visitorJWTForMerge = getVisitorJWTForMerge(libredeskSession)
|
||||
if (visitorJWTForMerge) {
|
||||
headers['X-Libredesk-Visitor-JWT'] = visitorJWTForMerge
|
||||
}
|
||||
|
||||
return axios.post('/api/v1/widget/media/upload', formData, {
|
||||
headers,
|
||||
timeout: 30000
|
||||
}).then((response) => {
|
||||
if (response.headers['x-libredesk-clear-visitor']) {
|
||||
clearVisitorJWT()
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
const updateConversationLastSeen = (uuid) => http.post(`/api/v1/widget/chat/conversations/${uuid}/update-last-seen`)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
:fileUploadEnabled="config.features?.file_upload || false"
|
||||
:emojiEnabled="config.features?.emoji || false"
|
||||
:uploading="isUploading"
|
||||
:canUploadFiles="!!chatStore.currentConversation?.uuid"
|
||||
@fileUpload="handleFileUpload"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
@@ -59,7 +60,7 @@ import { sendWidgetTyping } from '../websocket.js'
|
||||
import { convertTextToHtml } from '@shared-ui/utils/string.js'
|
||||
import { useTypingIndicator } from '@shared-ui/composables/useTypingIndicator.js'
|
||||
import MessageInputActions from './MessageInputActions.vue'
|
||||
import api from '@widget/api/index.js'
|
||||
import api, { setVisitorJWT } from '@widget/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
formData: {
|
||||
@@ -98,9 +99,9 @@ const initChatConversation = async (messageText) => {
|
||||
const resp = await api.initChatConversation(payload)
|
||||
const { conversation, jwt, messages } = resp.data.data
|
||||
|
||||
// Set user session token if not already set.
|
||||
if (!userStore.userSessionToken) {
|
||||
if (!userStore.userSessionToken && jwt) {
|
||||
userStore.setSessionToken(jwt)
|
||||
setVisitorJWT(jwt)
|
||||
}
|
||||
|
||||
// Add the new conversation to the list
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<!-- File Upload Button -->
|
||||
<Button
|
||||
v-if="fileUploadEnabled"
|
||||
v-if="fileUploadEnabled && canUploadFiles"
|
||||
@click="triggerFileUpload"
|
||||
:disabled="uploading"
|
||||
variant="ghost"
|
||||
@@ -67,6 +67,10 @@ const props = defineProps({
|
||||
uploading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canUploadFiles: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -320,6 +320,29 @@ FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
LEFT JOIN LATERAL unnest(r.permissions) AS p ON true
|
||||
WHERE u.deleted_at IS NULL
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND u.external_user_id = $1
|
||||
GROUP BY u.id;
|
||||
|
||||
-- name: merge-visitor-to-contact
|
||||
WITH transfer_conversations AS (
|
||||
UPDATE conversations
|
||||
SET contact_id = $2, updated_at = now()
|
||||
WHERE contact_id = $1
|
||||
RETURNING id
|
||||
),
|
||||
transfer_messages AS (
|
||||
UPDATE conversation_messages
|
||||
SET sender_id = $2
|
||||
WHERE sender_id = $1 AND sender_type = 'contact'
|
||||
RETURNING id
|
||||
),
|
||||
delete_visitor AS (
|
||||
DELETE FROM users
|
||||
WHERE id = $1 AND type = 'visitor'
|
||||
RETURNING id
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM transfer_conversations) as conversations_transferred,
|
||||
(SELECT COUNT(*) FROM transfer_messages) as messages_transferred,
|
||||
(SELECT COUNT(*) FROM delete_visitor) as visitor_deleted;
|
||||
|
||||
+14
-4
@@ -87,10 +87,11 @@ type queries struct {
|
||||
InsertVisitor *sqlx.Stmt `query:"insert-visitor"`
|
||||
ToggleEnable *sqlx.Stmt `query:"toggle-enable"`
|
||||
// API key queries
|
||||
GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"`
|
||||
SetAPIKey *sqlx.Stmt `query:"set-api-key"`
|
||||
RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"`
|
||||
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
|
||||
GetUserByAPIKey *sqlx.Stmt `query:"get-user-by-api-key"`
|
||||
SetAPIKey *sqlx.Stmt `query:"set-api-key"`
|
||||
RevokeAPIKey *sqlx.Stmt `query:"revoke-api-key"`
|
||||
UpdateAPIKeyLastUsed *sqlx.Stmt `query:"update-api-key-last-used"`
|
||||
MergeVisitorToContact *sqlx.Stmt `query:"merge-visitor-to-contact"`
|
||||
}
|
||||
|
||||
// New creates and returns a new instance of the Manager.
|
||||
@@ -356,6 +357,15 @@ func (u *Manager) RevokeAPIKey(userID int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeVisitorToContact transfers conversations from visitor to contact and deletes the visitor.
|
||||
func (u *Manager) MergeVisitorToContact(visitorID, contactID int) error {
|
||||
if _, err := u.q.MergeVisitorToContact.Exec(visitorID, contactID); err != nil {
|
||||
u.lo.Error("error merging visitor to contact", "visitor_id", visitorID, "contact_id", contactID, "error", err)
|
||||
return fmt.Errorf("merging visitor to contact: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangeSystemUserPassword updates the system user's password with a newly prompted one.
|
||||
func ChangeSystemUserPassword(ctx context.Context, db *sqlx.DB) error {
|
||||
// Prompt for password and get hashed password
|
||||
|
||||
Reference in New Issue
Block a user