package handlers import ( "encoding/json" "errors" "net/http" "regexp" "sort" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/trackeep/backend/models" "github.com/trackeep/backend/services" "github.com/trackeep/backend/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) var messageURLRegex = regexp.MustCompile(`https?://[^\s]+`) var messagesWSUpgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true }, } type CreateConversationRequest struct { Type string `json:"type" binding:"required"` Name string `json:"name"` Topic string `json:"topic"` TeamID *uint `json:"team_id"` UserIDs []uint `json:"user_ids"` } type AddConversationMemberRequest struct { UserID uint `json:"user_id" binding:"required"` Role string `json:"role"` } type AttachmentInput struct { Kind string `json:"kind"` FileID *uint `json:"file_id"` URL string `json:"url"` Title string `json:"title"` } type ReferenceInput struct { EntityType string `json:"entity_type"` EntityID uint `json:"entity_id"` DeepLink string `json:"deep_link"` } type CreateMessageRequest struct { Body string `json:"body"` Attachments []AttachmentInput `json:"attachments"` Metadata map[string]interface{} `json:"metadata"` References []ReferenceInput `json:"references"` } type UpdateMessageRequest struct { Body string `json:"body" binding:"required"` } type CreateReactionRequest struct { Emoji string `json:"emoji" binding:"required"` } type MessageSearchRequest struct { Query string `json:"query"` ConversationIDs []uint `json:"conversation_ids"` SenderID *uint `json:"sender_id"` DateFrom *time.Time `json:"date_from"` DateTo *time.Time `json:"date_to"` AttachmentKinds []string `json:"attachment_kinds"` ReferenceTypes []string `json:"reference_types"` HasLinks *bool `json:"has_links"` HasAttachments *bool `json:"has_attachments"` HasSuggestions *bool `json:"has_suggestions"` MentionOnly bool `json:"mention_only"` Limit int `json:"limit"` Offset int `json:"offset"` } type SuggestionActionRequest struct { RedactOriginal bool `json:"redact_original"` } type CreateVaultItemRequest struct { Label string `json:"label" binding:"required"` Secret string `json:"secret" binding:"required"` Notes string `json:"notes"` SourceMessageID *uint `json:"source_message_id"` } type ShareVaultItemRequest struct { TargetConversationID uint `json:"target_conversation_id" binding:"required"` ExpiresAt *time.Time `json:"expires_at"` AllowReveal bool `json:"allow_reveal"` } type UnshareVaultItemRequest struct { TargetConversationID uint `json:"target_conversation_id"` } type conversationListItem struct { Conversation models.Conversation `json:"conversation"` Role string `json:"role"` UnreadCount int64 `json:"unread_count"` LastMessage *models.Message `json:"last_message,omitempty"` } // GetConversations lists all conversations for the current user. func GetConversations(c *gin.Context) { userID := getAuthUserID(c) if userID == 0 { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } if err := ensureMessagingDefaults(models.DB, userID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize messaging defaults"}) return } var conversations []models.Conversation if err := models.DB. Joins("JOIN conversation_members cm ON cm.conversation_id = conversations.id"). Where("cm.user_id = ? AND cm.deleted_at IS NULL AND cm.is_hidden = false", userID). Preload("Members"). Order("COALESCE(conversations.last_message_at, conversations.updated_at) DESC"). Find(&conversations).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch conversations"}) return } items := make([]conversationListItem, 0, len(conversations)) for _, conv := range conversations { var membership models.ConversationMember if err := models.DB.Where("conversation_id = ? AND user_id = ?", conv.ID, userID).First(&membership).Error; err != nil { continue } var unreadCount int64 unreadQuery := models.DB.Model(&models.Message{}). Where("conversation_id = ? AND deleted_at IS NULL AND sender_id <> ?", conv.ID, userID) if membership.LastReadMessageID != nil { unreadQuery = unreadQuery.Where("id > ?", *membership.LastReadMessageID) } unreadQuery.Count(&unreadCount) var lastMessage models.Message var lastMessagePtr *models.Message if err := models.DB.Where("conversation_id = ? AND deleted_at IS NULL", conv.ID). Order("id DESC").Limit(1). Preload("Sender"). First(&lastMessage).Error; err == nil { lastMessagePtr = &lastMessage } items = append(items, conversationListItem{ Conversation: conv, Role: string(membership.Role), UnreadCount: unreadCount, LastMessage: lastMessagePtr, }) } c.JSON(http.StatusOK, gin.H{"conversations": items}) } // CreateConversation creates a new conversation (dm/group/team). func CreateConversation(c *gin.Context) { userID := getAuthUserID(c) if userID == 0 { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } var req CreateConversationRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } convType := models.ConversationType(req.Type) switch convType { case models.ConversationTypeDM, models.ConversationTypeGroup, models.ConversationTypeTeam: default: c.JSON(http.StatusBadRequest, gin.H{"error": "Only dm, group, and team conversations can be created explicitly"}) return } if err := ensureMessagingDefaults(models.DB, userID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize messaging defaults"}) return } // Direct message conversations are unique per user pair. if convType == models.ConversationTypeDM { if len(req.UserIDs) != 1 { c.JSON(http.StatusBadRequest, gin.H{"error": "DM conversation requires exactly one target user_id"}) return } targetUserID := req.UserIDs[0] if targetUserID == userID { c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot create DM with yourself; use self conversation"}) return } var target models.User if err := models.DB.First(&target, targetUserID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Target user not found"}) return } if existing := findExistingDM(models.DB, userID, targetUserID); existing != nil { c.JSON(http.StatusOK, gin.H{"conversation": existing}) return } conv := models.Conversation{ Type: models.ConversationTypeDM, Name: req.Name, Topic: req.Topic, CreatedBy: userID, } if conv.Name == "" { conv.Name = "Direct Message" } if err := models.DB.Create(&conv).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create conversation"}) return } members := []models.ConversationMember{ { ConversationID: conv.ID, UserID: userID, Role: models.ConversationMemberRoleMember, JoinedAt: time.Now(), }, { ConversationID: conv.ID, UserID: targetUserID, Role: models.ConversationMemberRoleMember, JoinedAt: time.Now(), }, } if err := models.DB.Create(&members).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add members"}) return } models.DB.Preload("Members").First(&conv, conv.ID) c.JSON(http.StatusCreated, gin.H{"conversation": conv}) return } if strings.TrimSpace(req.Name) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Conversation name is required"}) return } conv := models.Conversation{ Type: convType, Name: strings.TrimSpace(req.Name), Topic: req.Topic, CreatedBy: userID, } memberIDs := make(map[uint]struct{}) memberIDs[userID] = struct{}{} if convType == models.ConversationTypeTeam { if req.TeamID == nil || *req.TeamID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "team_id is required for team conversations"}) return } var teamMemberCount int64 models.DB.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", *req.TeamID, userID).Count(&teamMemberCount) if teamMemberCount == 0 { c.JSON(http.StatusForbidden, gin.H{"error": "You must be a team member to create a team conversation"}) return } conv.TeamID = req.TeamID var teamMembers []models.TeamMember models.DB.Where("team_id = ?", *req.TeamID).Find(&teamMembers) for _, tm := range teamMembers { memberIDs[tm.UserID] = struct{}{} } } else { for _, uid := range req.UserIDs { if uid == 0 { continue } memberIDs[uid] = struct{}{} } } memberSlice := make([]uint, 0, len(memberIDs)) for uid := range memberIDs { memberSlice = append(memberSlice, uid) } if !usersExist(models.DB, memberSlice) { c.JSON(http.StatusBadRequest, gin.H{"error": "One or more users do not exist"}) return } if err := models.DB.Create(&conv).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create conversation"}) return } members := make([]models.ConversationMember, 0, len(memberSlice)) for _, uid := range memberSlice { role := models.ConversationMemberRoleMember if uid == userID { role = models.ConversationMemberRoleOwner } members = append(members, models.ConversationMember{ ConversationID: conv.ID, UserID: uid, Role: role, JoinedAt: time.Now(), }) } if err := models.DB.Create(&members).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add conversation members"}) return } models.DB.Preload("Members").First(&conv, conv.ID) services.GetMessagesHub().Broadcast(conv.ID, "conversation.updated", gin.H{"conversation_id": conv.ID}) c.JSON(http.StatusCreated, gin.H{"conversation": conv}) } // GetConversation retrieves a specific conversation if user has access. func GetConversation(c *gin.Context) { userID := getAuthUserID(c) conversationID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) return } conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) return } c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var members []models.ConversationMember models.DB.Where("conversation_id = ?", conversationID). Preload("User"). Find(&members) c.JSON(http.StatusOK, gin.H{ "conversation": conv, "membership": member, "members": members, }) } // UpdateConversation updates mutable conversation fields. func UpdateConversation(c *gin.Context) { userID := getAuthUserID(c) conversationID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) return } conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) return } if !isConversationAdmin(member.Role) { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } if conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault { c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot mutate this conversation"}) return } var req struct { Name *string `json:"name"` Topic *string `json:"topic"` IsArchived *bool `json:"is_archived"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } updates := map[string]interface{}{} if req.Name != nil { updates["name"] = strings.TrimSpace(*req.Name) } if req.Topic != nil { updates["topic"] = *req.Topic } if req.IsArchived != nil { updates["is_archived"] = *req.IsArchived } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "No updates provided"}) return } if err := models.DB.Model(&conv).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update conversation"}) return } models.DB.First(&conv, conv.ID) services.GetMessagesHub().Broadcast(conv.ID, "conversation.updated", gin.H{"conversation_id": conv.ID}) c.JSON(http.StatusOK, gin.H{"conversation": conv}) } // AddConversationMember adds a user to an existing conversation. func AddConversationMember(c *gin.Context) { userID := getAuthUserID(c) conversationID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) return } conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) return } if !isConversationAdmin(member.Role) { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } if conv.Type == models.ConversationTypeDM || conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault || conv.Type == models.ConversationTypeGlobal { c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot add members to this conversation type"}) return } var req AddConversationMemberRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var target models.User if err := models.DB.First(&target, req.UserID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } if conv.Type == models.ConversationTypeTeam && conv.TeamID != nil { var teamMemberCount int64 models.DB.Model(&models.TeamMember{}).Where("team_id = ? AND user_id = ?", *conv.TeamID, req.UserID).Count(&teamMemberCount) if teamMemberCount == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "User is not part of this team"}) return } } role := models.ConversationMemberRoleMember if req.Role != "" { role = models.ConversationMemberRole(req.Role) } memberRow := models.ConversationMember{ ConversationID: conversationID, UserID: req.UserID, Role: role, JoinedAt: time.Now(), } if err := models.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&memberRow).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add member"}) return } services.GetMessagesHub().Broadcast(conversationID, "conversation.updated", gin.H{"conversation_id": conversationID}) c.JSON(http.StatusOK, gin.H{"message": "Member added"}) } // RemoveConversationMember removes a user from a conversation. func RemoveConversationMember(c *gin.Context) { userID := getAuthUserID(c) conversationID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) return } targetUserID, err := parseUintParam(c, "userId") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid target user id"}) return } conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"}) return } if conv.Type == models.ConversationTypeSelf || conv.Type == models.ConversationTypePasswordVault || conv.Type == models.ConversationTypeGlobal { c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot remove members from this conversation type"}) return } if targetUserID != userID && !isConversationAdmin(member.Role) { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } if err := models.DB.Where("conversation_id = ? AND user_id = ?", conversationID, targetUserID).Delete(&models.ConversationMember{}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove member"}) return } services.GetMessagesHub().Broadcast(conversationID, "conversation.updated", gin.H{"conversation_id": conversationID}) c.JSON(http.StatusOK, gin.H{"message": "Member removed"}) } // GetConversationMessages fetches messages with cursor-based pagination. func GetConversationMessages(c *gin.Context) { userID := getAuthUserID(c) conversationID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) return } if _, member, err := getConversationWithMembership(models.DB, conversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } else if member.Role == models.ConversationMemberRoleViewer { // Viewers can read messages. } limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) if limit <= 0 { limit = 50 } if limit > 100 { limit = 100 } cursor, _ := strconv.ParseUint(c.DefaultQuery("cursor", "0"), 10, 64) query := models.DB.Where("conversation_id = ?", conversationID). Preload("Sender"). Preload("Attachments"). Preload("References"). Preload("Suggestions"). Preload("Reactions"). Order("id DESC"). Limit(limit) if cursor > 0 { query = query.Where("id < ?", cursor) } var messages []models.Message if err := query.Find(&messages).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"}) return } // Reverse to ascending for timeline rendering. for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { messages[i], messages[j] = messages[j], messages[i] } var nextCursor uint if len(messages) > 0 { nextCursor = messages[0].ID } // Update read marker only when this is latest-page fetch. if cursor == 0 && len(messages) > 0 { lastID := messages[len(messages)-1].ID now := time.Now() models.DB.Model(&models.ConversationMember{}). Where("conversation_id = ? AND user_id = ?", conversationID, userID). Updates(map[string]interface{}{ "last_read_message_id": lastID, "last_read_at": &now, }) services.GetMessagesHub().Broadcast(conversationID, "read.updated", gin.H{ "user_id": userID, "conversation_id": conversationID, "last_read_message_id": lastID, }) } c.JSON(http.StatusOK, gin.H{ "messages": messages, "next_cursor": nextCursor, }) } // CreateConversationMessage posts a new message to a conversation. func CreateConversationMessage(c *gin.Context) { userID := getAuthUserID(c) conversationID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid conversation id"}) return } conv, member, err := getConversationWithMembership(models.DB, conversationID, userID) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } if !canWriteMessage(member.Role) { c.JSON(http.StatusForbidden, gin.H{"error": "You do not have write access in this conversation"}) return } if conv.IsArchived { c.JSON(http.StatusBadRequest, gin.H{"error": "Conversation is archived"}) return } var req CreateMessageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } trimmedBody := strings.TrimSpace(req.Body) if trimmedBody == "" && len(req.Attachments) == 0 && len(req.References) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Message body, attachments, or references are required"}) return } attachmentRows := make([]models.MessageAttachment, 0, len(req.Attachments)) for _, a := range req.Attachments { attachmentRows = append(attachmentRows, models.MessageAttachment{ Kind: normalizeAttachmentKind(a.Kind), FileID: a.FileID, URL: a.URL, Title: a.Title, }) } referenceRows := make([]models.MessageReference, 0, len(req.References)) for _, ref := range req.References { entityType := normalizeReferenceType(ref.EntityType) if entityType == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reference entity_type"}) return } if ref.EntityID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reference entity_id"}) return } deepLink := strings.TrimSpace(ref.DeepLink) if deepLink == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid reference deep_link"}) return } if !isReferenceDeepLinkAllowed(deepLink) { c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported reference deep_link"}) return } if !canReferenceEntity(models.DB, userID, entityType, ref.EntityID) { c.JSON(http.StatusForbidden, gin.H{"error": "Reference target is not accessible"}) return } referenceRows = append(referenceRows, models.MessageReference{ EntityType: entityType, EntityID: ref.EntityID, DeepLink: deepLink, }) } suggestions, inferredAttachments, isSensitive := services.DetectMessageContent(trimmedBody) for _, inferred := range inferredAttachments { if hasAttachment(attachmentRows, inferred.Kind, inferred.URL) { continue } previewJSON := "{}" if raw, err := json.Marshal(inferred.PreviewMap); err == nil { previewJSON = string(raw) } attachmentRows = append(attachmentRows, models.MessageAttachment{ Kind: normalizeAttachmentKind(inferred.Kind), URL: inferred.URL, Title: inferred.Title, PreviewJSON: previewJSON, }) } metadataMap := map[string]interface{}{} for k, v := range req.Metadata { metadataMap[k] = v } storedBody := trimmedBody if isSensitive && (conv.Type == models.ConversationTypeDM || conv.Type == models.ConversationTypeSelf) && trimmedBody != "" { ciphertext, err := utils.Encrypt(trimmedBody) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt sensitive message"}) return } storedBody = maskSensitiveBody(trimmedBody) metadataMap["sensitive_payload"] = map[string]interface{}{ "version": "v1", "ciphertext": ciphertext, "masked_body": storedBody, "scope": string(conv.Type), } } metadataJSON := "{}" if len(metadataMap) > 0 { if raw, err := json.Marshal(metadataMap); err == nil { metadataJSON = string(raw) } } message := models.Message{ ConversationID: conversationID, SenderID: userID, Body: storedBody, MetadataJSON: metadataJSON, } if err := models.DB.Create(&message).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create message"}) return } for i := range attachmentRows { attachmentRows[i].MessageID = message.ID } if len(attachmentRows) > 0 { models.DB.Create(&attachmentRows) } for i := range referenceRows { referenceRows[i].MessageID = message.ID } if len(referenceRows) > 0 { models.DB.Create(&referenceRows) } if len(suggestions) > 0 { suggestionRows := make([]models.MessageSuggestion, 0, len(suggestions)) for _, s := range suggestions { payloadJSON := "{}" if raw, err := json.Marshal(s.Payload); err == nil { payloadJSON = string(raw) } suggestionRows = append(suggestionRows, models.MessageSuggestion{ MessageID: message.ID, Type: s.Type, PayloadJSON: payloadJSON, Status: models.SuggestionStatusPending, }) } models.DB.Create(&suggestionRows) } if isSensitive { models.DB.Model(&message).Update("is_sensitive", true) } now := time.Now() models.DB.Model(&models.Conversation{}).Where("id = ?", conversationID).Update("last_message_at", &now) var freshMessage models.Message models.DB. Preload("Sender"). Preload("Attachments"). Preload("References"). Preload("Suggestions"). Preload("Reactions"). First(&freshMessage, message.ID) services.GetMessagesHub().Broadcast(conversationID, "message.created", freshMessage) response := gin.H{"message": freshMessage} if isSensitive { response["warning"] = "Sensitive data detected. We recommend a dedicated password manager like Proton Pass (not affiliated)." } c.JSON(http.StatusCreated, response) } // UpdateMessage edits an existing message. func UpdateMessage(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } var req UpdateMessageRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } _, member, err := getConversationWithMembership(models.DB, msg.ConversationID, userID) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } if msg.SenderID != userID && !isConversationAdmin(member.Role) { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } if msg.DeletedAt != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Deleted messages cannot be edited"}) return } now := time.Now() if err := models.DB.Model(&msg).Updates(map[string]interface{}{ "body": strings.TrimSpace(req.Body), "edited_at": &now, }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update message"}) return } models.DB. Preload("Sender"). Preload("Attachments"). Preload("References"). Preload("Suggestions"). Preload("Reactions"). First(&msg, msg.ID) services.GetMessagesHub().Broadcast(msg.ConversationID, "message.updated", msg) c.JSON(http.StatusOK, gin.H{"message": msg}) } // DeleteMessage marks a message as deleted. func DeleteMessage(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } _, member, err := getConversationWithMembership(models.DB, msg.ConversationID, userID) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } if msg.SenderID != userID && !isConversationAdmin(member.Role) { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } now := time.Now() if err := models.DB.Model(&msg).Updates(map[string]interface{}{ "deleted_at": &now, "body": "[deleted]", "edited_at": &now, }).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete message"}) return } services.GetMessagesHub().Broadcast(msg.ConversationID, "message.deleted", gin.H{ "message_id": msg.ID, "conversation_id": msg.ConversationID, }) c.JSON(http.StatusOK, gin.H{"message": "Message deleted"}) } // AddMessageReaction adds an emoji reaction to a message. func AddMessageReaction(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } var req CreateReactionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } emoji := strings.TrimSpace(req.Emoji) if emoji == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"}) return } var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } reaction := models.MessageReaction{ MessageID: messageID, UserID: userID, Emoji: emoji, } if err := models.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&reaction).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add reaction"}) return } models.DB.Where("message_id = ? AND user_id = ? AND emoji = ?", messageID, userID, emoji).First(&reaction) services.GetMessagesHub().Broadcast(msg.ConversationID, "reaction.added", reaction) c.JSON(http.StatusOK, gin.H{"reaction": reaction}) } // RemoveMessageReaction removes the current user's reaction from a message. func RemoveMessageReaction(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } emoji := strings.TrimSpace(c.Param("emoji")) if emoji == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Emoji is required"}) return } var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } if err := models.DB.Where("message_id = ? AND user_id = ? AND emoji = ?", messageID, userID, emoji).Delete(&models.MessageReaction{}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove reaction"}) return } services.GetMessagesHub().Broadcast(msg.ConversationID, "reaction.removed", gin.H{ "message_id": messageID, "user_id": userID, "emoji": emoji, }) c.JSON(http.StatusOK, gin.H{"message": "Reaction removed"}) } // SearchMessages performs filtered search over messages (excluding password vault). func SearchMessages(c *gin.Context) { userID := getAuthUserID(c) if userID == 0 { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } var req MessageSearchRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Limit <= 0 { req.Limit = 50 } if req.Limit > 100 { req.Limit = 100 } if req.Offset < 0 { req.Offset = 0 } baseQuery := models.DB.Model(&models.Message{}). Joins("JOIN conversations ON conversations.id = messages.conversation_id"). Joins("JOIN conversation_members cm ON cm.conversation_id = messages.conversation_id"). Where("cm.user_id = ? AND cm.deleted_at IS NULL", userID). Where("conversations.type <> ?", models.ConversationTypePasswordVault). Where("messages.deleted_at IS NULL") if strings.TrimSpace(req.Query) != "" { like := "%" + strings.ToLower(strings.TrimSpace(req.Query)) + "%" baseQuery = baseQuery.Where("LOWER(messages.body) LIKE ?", like) } if len(req.ConversationIDs) > 0 { baseQuery = baseQuery.Where("messages.conversation_id IN ?", req.ConversationIDs) } if req.SenderID != nil { baseQuery = baseQuery.Where("messages.sender_id = ?", *req.SenderID) } if req.DateFrom != nil { baseQuery = baseQuery.Where("messages.created_at >= ?", *req.DateFrom) } if req.DateTo != nil { baseQuery = baseQuery.Where("messages.created_at <= ?", *req.DateTo) } if req.MentionOnly { var user models.User if err := models.DB.First(&user, userID).Error; err == nil { mentionNeedle := "%@" + strings.ToLower(user.Username) + "%" baseQuery = baseQuery.Where("LOWER(messages.body) LIKE ?", mentionNeedle) } } if req.HasAttachments != nil { if *req.HasAttachments { baseQuery = baseQuery.Joins("JOIN message_attachments ma_any ON ma_any.message_id = messages.id") } else { baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_attachments ma WHERE ma.message_id = messages.id)") } } if req.HasLinks != nil { if *req.HasLinks { baseQuery = baseQuery.Joins("JOIN message_attachments ma_links ON ma_links.message_id = messages.id AND ma_links.url <> ''") } else { baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_attachments ma WHERE ma.message_id = messages.id AND ma.url <> '')") } } if req.HasSuggestions != nil { if *req.HasSuggestions { baseQuery = baseQuery.Joins("JOIN message_suggestions ms_any ON ms_any.message_id = messages.id") } else { baseQuery = baseQuery.Where("NOT EXISTS (SELECT 1 FROM message_suggestions ms WHERE ms.message_id = messages.id)") } } if len(req.AttachmentKinds) > 0 { baseQuery = baseQuery.Joins("JOIN message_attachments ma_kind ON ma_kind.message_id = messages.id AND ma_kind.kind IN ?", req.AttachmentKinds) } if len(req.ReferenceTypes) > 0 { baseQuery = baseQuery.Joins("JOIN message_references mr_type ON mr_type.message_id = messages.id AND mr_type.entity_type IN ?", req.ReferenceTypes) } var total int64 if err := baseQuery.Session(&gorm.Session{}).Distinct("messages.id").Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count messages"}) return } var messageIDs []uint if err := baseQuery.Session(&gorm.Session{}). Distinct("messages.id"). Order("messages.created_at DESC"). Offset(req.Offset). Limit(req.Limit). Pluck("messages.id", &messageIDs).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search messages"}) return } if len(messageIDs) == 0 { c.JSON(http.StatusOK, gin.H{ "results": []models.Message{}, "total": total, "limit": req.Limit, "offset": req.Offset, }) return } var messages []models.Message if err := models.DB. Where("id IN ?", messageIDs). Preload("Sender"). Preload("Attachments"). Preload("References"). Preload("Suggestions"). Preload("Reactions"). Find(&messages).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search messages"}) return } order := make(map[uint]int, len(messageIDs)) for i, id := range messageIDs { order[id] = i } sort.Slice(messages, func(i, j int) bool { return order[messages[i].ID] < order[messages[j].ID] }) c.JSON(http.StatusOK, gin.H{ "results": messages, "total": total, "limit": req.Limit, "offset": req.Offset, }) } // GetMessageSuggestions returns suggestions for a specific message. func GetMessageSuggestions(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var suggestions []models.MessageSuggestion if err := models.DB.Where("message_id = ?", messageID).Order("id ASC").Find(&suggestions).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch suggestions"}) return } c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) } // AcceptMessageSuggestion applies a suggestion action and marks it accepted. func AcceptMessageSuggestion(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } suggestionID, err := parseUintParam(c, "suggestionId") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid suggestion id"}) return } var req SuggestionActionRequest _ = c.ShouldBindJSON(&req) var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var suggestion models.MessageSuggestion if err := models.DB.Where("id = ? AND message_id = ?", suggestionID, messageID).First(&suggestion).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) return } if suggestion.Status != models.SuggestionStatusPending { c.JSON(http.StatusBadRequest, gin.H{"error": "Suggestion is not pending"}) return } result, err := applySuggestionAction(models.DB, userID, &msg, &suggestion, req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } suggestion.Status = models.SuggestionStatusAccepted models.DB.Save(&suggestion) services.GetMessagesHub().Broadcast(msg.ConversationID, "conversation.updated", gin.H{ "message_id": msg.ID, "suggestion_id": suggestion.ID, "status": suggestion.Status, }) c.JSON(http.StatusOK, gin.H{ "message": "Suggestion accepted", "result": result, "suggestion": suggestion, }) } // DismissMessageSuggestion marks a suggestion dismissed. func DismissMessageSuggestion(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } suggestionID, err := parseUintParam(c, "suggestionId") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid suggestion id"}) return } var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } var suggestion models.MessageSuggestion if err := models.DB.Where("id = ? AND message_id = ?", suggestionID, messageID).First(&suggestion).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Suggestion not found"}) return } suggestion.Status = models.SuggestionStatusDismissed if err := models.DB.Save(&suggestion).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to dismiss suggestion"}) return } c.JSON(http.StatusOK, gin.H{"suggestion": suggestion}) } // RevealSensitiveMessage decrypts and returns sensitive message plaintext for authorized members. func RevealSensitiveMessage(c *gin.Context) { userID := getAuthUserID(c) messageID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid message id"}) return } var msg models.Message if err := models.DB.First(&msg, messageID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Message not found"}) return } if _, _, err := getConversationWithMembership(models.DB, msg.ConversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } plaintext, ok := extractSensitivePlaintext(msg.MetadataJSON) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "Sensitive payload not found"}) return } c.JSON(http.StatusOK, gin.H{ "message_id": msg.ID, "plaintext": plaintext, }) } // GetPasswordVaultItems returns owned and explicitly shared vault items. func GetPasswordVaultItems(c *gin.Context) { userID := getAuthUserID(c) if userID == 0 { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } type vaultItemResponse struct { ID uint `json:"id"` Label string `json:"label"` OwnerUserID uint `json:"owner_user_id"` SourceMessageID *uint `json:"source_message_id,omitempty"` LastAccessedAt *time.Time `json:"last_accessed_at,omitempty"` Shared bool `json:"shared"` AllowReveal bool `json:"allow_reveal"` ExpiresAt *time.Time `json:"expires_at,omitempty"` TargetConversationID *uint `json:"target_conversation_id,omitempty"` } results := make([]vaultItemResponse, 0, 32) var owned []models.PasswordVaultItem models.DB.Where("owner_user_id = ?", userID).Find(&owned) for _, item := range owned { results = append(results, vaultItemResponse{ ID: item.ID, Label: item.Label, OwnerUserID: item.OwnerUserID, SourceMessageID: item.SourceMessageID, LastAccessedAt: item.LastAccessedAt, Shared: false, AllowReveal: true, }) } var shares []models.PasswordVaultShare models.DB. Joins("JOIN conversation_members cm ON cm.conversation_id = password_vault_shares.target_conversation_id"). Where("cm.user_id = ? AND cm.deleted_at IS NULL", userID). Where("password_vault_shares.expires_at IS NULL OR password_vault_shares.expires_at > ?", time.Now()). Preload("VaultItem"). Find(&shares) seen := map[uint]bool{} for _, ownedItem := range owned { seen[ownedItem.ID] = true } for _, share := range shares { item := share.VaultItem if seen[item.ID] { continue } seen[item.ID] = true targetID := share.TargetConversationID results = append(results, vaultItemResponse{ ID: item.ID, Label: item.Label, OwnerUserID: item.OwnerUserID, SourceMessageID: item.SourceMessageID, LastAccessedAt: item.LastAccessedAt, Shared: true, AllowReveal: share.AllowReveal, ExpiresAt: share.ExpiresAt, TargetConversationID: &targetID, }) } c.JSON(http.StatusOK, gin.H{"items": results}) } // CreatePasswordVaultItem creates a new encrypted vault item. func CreatePasswordVaultItem(c *gin.Context) { userID := getAuthUserID(c) if userID == 0 { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } var req CreateVaultItemRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } encryptedSecret, err := utils.Encrypt(req.Secret) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt secret"}) return } encryptedNotes := "" if strings.TrimSpace(req.Notes) != "" { encryptedNotes, err = utils.Encrypt(req.Notes) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt notes"}) return } } item := models.PasswordVaultItem{ OwnerUserID: userID, Label: strings.TrimSpace(req.Label), EncryptedSecret: encryptedSecret, EncryptedNotes: encryptedNotes, SourceMessageID: req.SourceMessageID, CreatedBy: userID, } if err := models.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create vault item"}) return } if req.SourceMessageID != nil && *req.SourceMessageID > 0 { models.DB.Model(&models.Message{}).Where("id = ?", *req.SourceMessageID).Update("is_sensitive", true) } c.JSON(http.StatusCreated, gin.H{ "item": gin.H{ "id": item.ID, "label": item.Label, "owner_user_id": item.OwnerUserID, "source_message_id": item.SourceMessageID, }, }) } // SharePasswordVaultItem shares an item to a selected conversation. func SharePasswordVaultItem(c *gin.Context) { userID := getAuthUserID(c) itemID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) return } var req ShareVaultItemRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var item models.PasswordVaultItem if err := models.DB.First(&item, itemID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) return } if item.OwnerUserID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can share vault items"}) return } if _, _, err := getConversationWithMembership(models.DB, req.TargetConversationID, userID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": "You are not a member of target conversation"}) return } var share models.PasswordVaultShare err = models.DB.Where("vault_item_id = ? AND target_conversation_id = ?", itemID, req.TargetConversationID).First(&share).Error if err == nil { share.ExpiresAt = req.ExpiresAt share.AllowReveal = req.AllowReveal share.SharedByUserID = userID models.DB.Save(&share) } else { share = models.PasswordVaultShare{ VaultItemID: itemID, SharedByUserID: userID, TargetConversationID: req.TargetConversationID, ExpiresAt: req.ExpiresAt, AllowReveal: req.AllowReveal, } if err := models.DB.Create(&share).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to share vault item"}) return } } c.JSON(http.StatusOK, gin.H{"share": share}) } // RevealPasswordVaultItem decrypts and returns a vault item secret if allowed. func RevealPasswordVaultItem(c *gin.Context) { userID := getAuthUserID(c) itemID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) return } var item models.PasswordVaultItem if err := models.DB.First(&item, itemID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) return } allowed := item.OwnerUserID == userID if !allowed { var share models.PasswordVaultShare err := models.DB. Joins("JOIN conversation_members cm ON cm.conversation_id = password_vault_shares.target_conversation_id"). Where("password_vault_shares.vault_item_id = ? AND cm.user_id = ? AND cm.deleted_at IS NULL", itemID, userID). Where("password_vault_shares.allow_reveal = true"). Where("password_vault_shares.expires_at IS NULL OR password_vault_shares.expires_at > ?", time.Now()). First(&share).Error if err == nil { allowed = true } } if !allowed { c.JSON(http.StatusForbidden, gin.H{"error": "You are not allowed to reveal this vault item"}) return } secret, err := utils.Decrypt(item.EncryptedSecret) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decrypt secret"}) return } notes := "" if strings.TrimSpace(item.EncryptedNotes) != "" { notes, _ = utils.Decrypt(item.EncryptedNotes) } now := time.Now() models.DB.Model(&item).Update("last_accessed_at", &now) c.JSON(http.StatusOK, gin.H{ "id": item.ID, "label": item.Label, "secret": secret, "notes": notes, "warning": "Use a dedicated password manager like Proton Pass (not affiliated) for best security hygiene.", }) } // UnsharePasswordVaultItem revokes one or all shares for an item. func UnsharePasswordVaultItem(c *gin.Context) { userID := getAuthUserID(c) itemID, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vault item id"}) return } var req UnshareVaultItemRequest _ = c.ShouldBindJSON(&req) var item models.PasswordVaultItem if err := models.DB.First(&item, itemID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Vault item not found"}) return } if item.OwnerUserID != userID { c.JSON(http.StatusForbidden, gin.H{"error": "Only owner can unshare vault items"}) return } query := models.DB.Where("vault_item_id = ? AND shared_by_user_id = ?", itemID, userID) if req.TargetConversationID > 0 { query = query.Where("target_conversation_id = ?", req.TargetConversationID) } if err := query.Delete(&models.PasswordVaultShare{}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unshare vault item"}) return } c.JSON(http.StatusOK, gin.H{"message": "Vault share removed"}) } // MessagesWebSocket upgrades to websocket and handles realtime messaging events. func MessagesWebSocket(c *gin.Context) { userID := getAuthUserID(c) if userID == 0 { c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) return } conn, err := messagesWSUpgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } hub := services.GetMessagesHub() client := services.NewWSClient(userID, conn) // Subscribe to all conversations this user is a member of. var memberships []models.ConversationMember models.DB.Where("user_id = ?", userID).Find(&memberships) for _, m := range memberships { hub.AddClientToConversation(client, m.ConversationID) } // Writer loop go func() { ticker := time.NewTicker(25 * time.Second) defer ticker.Stop() for { select { case raw, ok := <-client.Send: if !ok { _ = conn.WriteMessage(websocket.CloseMessage, []byte{}) return } if err := conn.WriteMessage(websocket.TextMessage, raw); err != nil { return } case <-ticker.C: if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }() // Reader loop for { var incoming map[string]interface{} if err := conn.ReadJSON(&incoming); err != nil { break } eventType, _ := incoming["type"].(string) conversationID := parseUintAny(incoming["conversation_id"]) switch eventType { case "subscribe": if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { hub.AddClientToConversation(client, conversationID) } case "unsubscribe": if conversationID > 0 { hub.RemoveClientFromConversation(client, conversationID) } case "typing.started", "typing.stopped": if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { hub.Broadcast(conversationID, eventType, gin.H{ "user_id": userID, "conversation_id": conversationID, }) } case "call.offer", "call.answer", "call.ice": targetUserID := parseUintAny(incoming["target_user_id"]) if conversationID > 0 && targetUserID > 0 && isConversationMember(models.DB, conversationID, userID) && isConversationMember(models.DB, conversationID, targetUserID) { payload := gin.H{ "conversation_id": conversationID, "sender_id": userID, "target_user_id": targetUserID, } if sdp, exists := incoming["sdp"]; exists { payload["sdp"] = sdp } if candidate, exists := incoming["candidate"]; exists { payload["candidate"] = candidate } if callID, exists := incoming["call_id"]; exists { payload["call_id"] = callID } hub.SendToUser(conversationID, targetUserID, eventType, payload) } case "call.hangup": if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { payload := gin.H{ "conversation_id": conversationID, "user_id": userID, } if callID, exists := incoming["call_id"]; exists { payload["call_id"] = callID } hub.Broadcast(conversationID, "call.hangup", payload) } case "read.updated": if conversationID > 0 && isConversationMember(models.DB, conversationID, userID) { lastReadID := parseUintAny(incoming["last_read_message_id"]) now := time.Now() models.DB.Model(&models.ConversationMember{}). Where("conversation_id = ? AND user_id = ?", conversationID, userID). Updates(map[string]interface{}{ "last_read_message_id": lastReadID, "last_read_at": &now, }) hub.Broadcast(conversationID, "read.updated", gin.H{ "user_id": userID, "conversation_id": conversationID, "last_read_message_id": lastReadID, }) } } } hub.RemoveClient(client) _ = conn.Close() } func applySuggestionAction(db *gorm.DB, userID uint, message *models.Message, suggestion *models.MessageSuggestion, req SuggestionActionRequest) (interface{}, error) { payload := map[string]interface{}{} if strings.TrimSpace(suggestion.PayloadJSON) != "" { _ = json.Unmarshal([]byte(suggestion.PayloadJSON), &payload) } switch suggestion.Type { case "create_task": title := asString(payload["title"]) if strings.TrimSpace(title) == "" { title = compactMessageTitle(message.Body, 80) } task := models.Task{ UserID: userID, Title: title, Description: message.Body, Status: models.TaskStatusPending, Priority: models.TaskPriorityMedium, } if err := db.Create(&task).Error; err != nil { return nil, err } ref := models.MessageReference{ MessageID: message.ID, EntityType: "task", EntityID: task.ID, DeepLink: "/app/tasks?id=" + strconv.Itoa(int(task.ID)), } db.Create(&ref) return gin.H{"task": task, "deep_link": ref.DeepLink}, nil case "create_event": title := asString(payload["title"]) if strings.TrimSpace(title) == "" { title = compactMessageTitle(message.Body, 80) } start := time.Now().Add(1 * time.Hour) end := start.Add(1 * time.Hour) event := models.CalendarEvent{ UserID: userID, Title: title, Description: message.Body, StartTime: start, EndTime: end, Type: "reminder", Priority: "medium", Source: "trackeep", ReminderMinutes: 15, } if err := db.Create(&event).Error; err != nil { return nil, err } ref := models.MessageReference{ MessageID: message.ID, EntityType: "calendar_event", EntityID: event.ID, DeepLink: "/app/calendar?eventId=" + strconv.Itoa(int(event.ID)), } db.Create(&ref) return gin.H{"event": event, "deep_link": ref.DeepLink}, nil case "save_bookmark": url := asString(payload["url"]) if strings.TrimSpace(url) == "" { url = firstURLFromText(message.Body) } if strings.TrimSpace(url) == "" { return nil, errors.New("no URL available for bookmark suggestion") } title := asString(payload["title"]) if strings.TrimSpace(title) == "" { title = url } bookmark := models.Bookmark{ UserID: userID, Title: title, URL: url, Description: "Created from chat suggestion", } if err := db.Create(&bookmark).Error; err != nil { return nil, err } ref := models.MessageReference{ MessageID: message.ID, EntityType: "bookmark", EntityID: bookmark.ID, DeepLink: "/app/bookmarks?id=" + strconv.Itoa(int(bookmark.ID)), } db.Create(&ref) return gin.H{"bookmark": bookmark, "deep_link": ref.DeepLink}, nil case "save_youtube": url := asString(payload["url"]) if strings.TrimSpace(url) == "" { url = firstURLFromText(message.Body) } videoID := extractYouTubeVideoID(url) if videoID == "" { return nil, errors.New("no valid YouTube URL found") } recordVideoID := videoID var existing models.VideoBookmark if err := db.Where("video_id = ?", recordVideoID).First(&existing).Error; err == nil && existing.UserID != userID { recordVideoID = videoID + "-" + strconv.Itoa(int(userID)) } video := models.VideoBookmark{ UserID: userID, VideoID: recordVideoID, Title: compactMessageTitle(message.Body, 80), Channel: "Unknown", Thumbnail: "https://img.youtube.com/vi/" + videoID + "/hqdefault.jpg", URL: url, Description: "Saved from chat suggestion", } if err := db.Create(&video).Error; err != nil { return nil, err } ref := models.MessageReference{ MessageID: message.ID, EntityType: "youtube_video", EntityID: video.ID, DeepLink: "/app/youtube?video=" + videoID, } db.Create(&ref) return gin.H{"video": video, "deep_link": ref.DeepLink}, nil case "save_search": queryText := asString(payload["query"]) if strings.TrimSpace(queryText) == "" { queryText = message.Body } saved := models.SavedSearch{ UserID: userID, Name: compactMessageTitle(queryText, 50), Query: queryText, Filters: "{}", Alert: false, Description: "Saved from chat suggestion", } if err := db.Create(&saved).Error; err != nil { return nil, err } ref := models.MessageReference{ MessageID: message.ID, EntityType: "saved_search", EntityID: saved.ID, DeepLink: "/app/search?savedId=" + strconv.Itoa(int(saved.ID)), } db.Create(&ref) return gin.H{"saved_search": saved, "deep_link": ref.DeepLink}, nil case "link_github": url := asString(payload["url"]) if strings.TrimSpace(url) == "" { url = firstURLFromText(message.Body) } ref := models.MessageReference{ MessageID: message.ID, EntityType: "github", EntityID: 0, DeepLink: "/app/github", } db.Create(&ref) return gin.H{"github_url": url, "deep_link": ref.DeepLink}, nil case "link_learning_path": ref := models.MessageReference{ MessageID: message.ID, EntityType: "learning_path", EntityID: 0, DeepLink: "/app/learning-paths", } db.Create(&ref) return gin.H{"deep_link": ref.DeepLink}, nil case "move_to_password_vault": secretSource := message.Body if sensitivePlaintext, ok := extractSensitivePlaintext(message.MetadataJSON); ok { secretSource = sensitivePlaintext } label := "Imported from chat" if compact := compactMessageTitle(secretSource, 50); compact != "" { label = compact } encryptedSecret, err := utils.Encrypt(secretSource) if err != nil { return nil, err } encryptedNotes, _ := utils.Encrypt("Imported from message #" + strconv.Itoa(int(message.ID))) item := models.PasswordVaultItem{ OwnerUserID: userID, Label: label, EncryptedSecret: encryptedSecret, EncryptedNotes: encryptedNotes, SourceMessageID: &message.ID, CreatedBy: userID, } if err := db.Create(&item).Error; err != nil { return nil, err } redactOriginal := req.RedactOriginal if req.RedactOriginal == false { // Default to redacting when vault action is accepted. redactOriginal = true } if redactOriginal { now := time.Now() db.Model(&models.Message{}).Where("id = ?", message.ID).Updates(map[string]interface{}{ "body": "[moved to vault]", "is_sensitive": true, "edited_at": &now, }) } ref := models.MessageReference{ MessageID: message.ID, EntityType: "password_vault_item", EntityID: item.ID, DeepLink: "/app/messages?vaultItem=" + strconv.Itoa(int(item.ID)), } db.Create(&ref) return gin.H{"vault_item_id": item.ID, "deep_link": ref.DeepLink}, nil case "password_warning": return gin.H{ "warning": "Sensitive content detected. We recommend Proton Pass (not affiliated).", }, nil } return nil, errors.New("unsupported suggestion type") } func ensureMessagingDefaults(db *gorm.DB, userID uint) error { globals, err := ensureGlobalConversations(db, userID) if err != nil { return err } // Ensure the current user is in all global channels. for _, conv := range globals { member := models.ConversationMember{ ConversationID: conv.ID, UserID: userID, Role: models.ConversationMemberRoleMember, JoinedAt: time.Now(), } if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil { return err } } if _, err := ensureUserConversation(db, userID, models.ConversationTypeSelf, "Notes to Self", true); err != nil { return err } if _, err := ensureUserConversation(db, userID, models.ConversationTypePasswordVault, "Password Vault", true); err != nil { return err } return nil } func ensureGlobalConversations(db *gorm.DB, userID uint) ([]models.Conversation, error) { defaults := []string{"#general", "#announcements"} out := make([]models.Conversation, 0, len(defaults)) for _, name := range defaults { var conv models.Conversation err := db.Where("type = ? AND name = ?", models.ConversationTypeGlobal, name).First(&conv).Error if errors.Is(err, gorm.ErrRecordNotFound) { conv = models.Conversation{ Type: models.ConversationTypeGlobal, Name: name, IsDefault: true, CreatedBy: userID, } if err := db.Create(&conv).Error; err != nil { return nil, err } } else if err != nil { return nil, err } out = append(out, conv) } // Backfill global membership for all users. var users []models.User if err := db.Find(&users).Error; err != nil { return nil, err } rows := make([]models.ConversationMember, 0, len(users)*len(out)) for _, conv := range out { for _, user := range users { rows = append(rows, models.ConversationMember{ ConversationID: conv.ID, UserID: user.ID, Role: models.ConversationMemberRoleMember, JoinedAt: time.Now(), }) } } if len(rows) > 0 { if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil { return nil, err } } return out, nil } func ensureUserConversation(db *gorm.DB, userID uint, conversationType models.ConversationType, name string, isDefault bool) (*models.Conversation, error) { var conv models.Conversation err := db. Where("type = ? AND created_by = ?", conversationType, userID). First(&conv).Error if errors.Is(err, gorm.ErrRecordNotFound) { conv = models.Conversation{ Type: conversationType, Name: name, IsDefault: isDefault, CreatedBy: userID, } if err := db.Create(&conv).Error; err != nil { return nil, err } } else if err != nil { return nil, err } member := models.ConversationMember{ ConversationID: conv.ID, UserID: userID, Role: models.ConversationMemberRoleOwner, JoinedAt: time.Now(), } if err := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&member).Error; err != nil { return nil, err } return &conv, nil } func getConversationWithMembership(db *gorm.DB, conversationID, userID uint) (*models.Conversation, *models.ConversationMember, error) { var conv models.Conversation if err := db.First(&conv, conversationID).Error; err != nil { return nil, nil, err } var member models.ConversationMember if err := db.Where("conversation_id = ? AND user_id = ?", conversationID, userID).First(&member).Error; err != nil { return nil, nil, err } return &conv, &member, nil } func isConversationAdmin(role models.ConversationMemberRole) bool { return role == models.ConversationMemberRoleOwner || role == models.ConversationMemberRoleAdmin } func canWriteMessage(role models.ConversationMemberRole) bool { return role == models.ConversationMemberRoleOwner || role == models.ConversationMemberRoleAdmin || role == models.ConversationMemberRoleMember } func getAuthUserID(c *gin.Context) uint { if uid := c.GetUint("user_id"); uid != 0 { return uid } if uid := c.GetUint("userID"); uid != 0 { return uid } return 0 } func parseUintParam(c *gin.Context, key string) (uint, error) { raw := c.Param(key) parsed, err := strconv.ParseUint(raw, 10, 32) if err != nil { return 0, err } return uint(parsed), nil } func parseUintAny(v interface{}) uint { switch t := v.(type) { case float64: return uint(t) case int: return uint(t) case uint: return t case string: p, _ := strconv.ParseUint(t, 10, 32) return uint(p) default: return 0 } } func findExistingDM(db *gorm.DB, userA, userB uint) *models.Conversation { var candidates []models.Conversation db.Joins("JOIN conversation_members cm ON cm.conversation_id = conversations.id"). Where("conversations.type = ? AND cm.user_id = ?", models.ConversationTypeDM, userA). Find(&candidates) for _, conv := range candidates { var members []models.ConversationMember db.Where("conversation_id = ?", conv.ID).Find(&members) if len(members) != 2 { continue } foundA, foundB := false, false for _, m := range members { if m.UserID == userA { foundA = true } if m.UserID == userB { foundB = true } } if foundA && foundB { return &conv } } return nil } func usersExist(db *gorm.DB, userIDs []uint) bool { if len(userIDs) == 0 { return true } var count int64 db.Model(&models.User{}).Where("id IN ?", userIDs).Count(&count) return int(count) == len(userIDs) } func hasAttachment(rows []models.MessageAttachment, kind, url string) bool { for _, row := range rows { if strings.EqualFold(row.Kind, kind) && strings.EqualFold(strings.TrimSpace(row.URL), strings.TrimSpace(url)) { return true } } return false } func maskSensitiveBody(text string) string { trimmed := strings.TrimSpace(text) if trimmed == "" { return "[sensitive content hidden]" } parts := strings.Fields(trimmed) if len(parts) == 0 { return "[sensitive content hidden]" } maskedParts := make([]string, 0, len(parts)) for _, part := range parts { runes := []rune(part) if len(runes) <= 2 { maskedParts = append(maskedParts, "**") continue } maskedParts = append(maskedParts, strings.Repeat("*", len(runes))) } return strings.Join(maskedParts, " ") } func extractSensitivePlaintext(metadataJSON string) (string, bool) { payload := extractSensitivePayload(metadataJSON) if payload == nil { return "", false } ciphertext := asString(payload["ciphertext"]) if ciphertext == "" { return "", false } plaintext, err := utils.Decrypt(ciphertext) if err != nil { return "", false } return plaintext, true } func extractSensitivePayload(metadataJSON string) map[string]interface{} { trimmed := strings.TrimSpace(metadataJSON) if trimmed == "" || trimmed == "{}" { return nil } metadata := map[string]interface{}{} if err := json.Unmarshal([]byte(trimmed), &metadata); err != nil { return nil } rawPayload, ok := metadata["sensitive_payload"] if !ok || rawPayload == nil { return nil } payload, ok := rawPayload.(map[string]interface{}) if !ok { return nil } return payload } func normalizeAttachmentKind(kind string) string { k := strings.ToLower(strings.TrimSpace(kind)) switch k { case "file", "image", "youtube", "github", "website", "bookmark", "task", "event", "calendar", "activity", "learning_path", "saved_search", "voice_note": return k default: return "website" } } func normalizeReferenceType(entityType string) string { t := strings.ToLower(strings.TrimSpace(entityType)) switch t { case "task", "bookmark", "calendar_event", "youtube_video", "learning_path", "saved_search", "github", "password_vault_item", "ai_chat_session", "ai_chat_message": return t default: return "" } } func isReferenceDeepLinkAllowed(deepLink string) bool { return strings.HasPrefix(deepLink, "/") || strings.HasPrefix(deepLink, "http://") || strings.HasPrefix(deepLink, "https://") } func canReferenceEntity(db *gorm.DB, userID uint, entityType string, entityID uint) bool { switch entityType { case "ai_chat_session": var session models.ChatSession return db.Where("id = ? AND user_id = ?", entityID, userID).First(&session).Error == nil case "ai_chat_message": var message models.ChatMessage return db.Where("id = ? AND user_id = ?", entityID, userID).First(&message).Error == nil default: return true } } func compactMessageTitle(text string, limit int) string { trimmed := strings.TrimSpace(text) if len(trimmed) <= limit { return trimmed } if limit < 4 { return trimmed } return strings.TrimSpace(trimmed[:limit-3]) + "..." } func firstURLFromText(text string) string { matches := messageURLRegex.FindAllString(text, -1) if len(matches) == 0 { return "" } return matches[0] } func extractYouTubeVideoID(rawURL string) string { if strings.Contains(rawURL, "youtu.be/") { parts := strings.Split(rawURL, "youtu.be/") if len(parts) > 1 { idPart := parts[1] if idx := strings.IndexAny(idPart, "?&/"); idx > -1 { idPart = idPart[:idx] } return idPart } } if strings.Contains(rawURL, "youtube.com/watch?v=") { parts := strings.Split(rawURL, "watch?v=") if len(parts) > 1 { idPart := parts[1] if idx := strings.IndexAny(idPart, "&/"); idx > -1 { idPart = idPart[:idx] } return idPart } } return "" } func asString(v interface{}) string { if s, ok := v.(string); ok { return strings.TrimSpace(s) } return "" } func isConversationMember(db *gorm.DB, conversationID, userID uint) bool { var count int64 db.Model(&models.ConversationMember{}).Where("conversation_id = ? AND user_id = ?", conversationID, userID).Count(&count) return count > 0 }