diff --git a/chat/chat.go b/chat/chat.go index 3e71670f..f82f98af 100644 --- a/chat/chat.go +++ b/chat/chat.go @@ -105,6 +105,39 @@ type Client struct { var rooms = make(map[string]*Room) var roomsMutex sync.RWMutex +// ChatMessage represents a direct message between users +type ChatMessage struct { + ID string `json:"id"` + From string `json:"from"` // Sender username + FromID string `json:"from_id"` // Sender account ID + To string `json:"to"` // Recipient username + ToID string `json:"to_id"` // Recipient account ID + Body string `json:"body"` + Read bool `json:"read"` + ReplyTo string `json:"reply_to"` // ID of message this is replying to + ThreadID string `json:"thread_id"` // Root message ID for O(1) thread grouping + CreatedAt time.Time `json:"created_at"` +} + +// ChatThread represents a conversation thread +type ChatThread struct { + Root *ChatMessage + Messages []*ChatMessage + Latest *ChatMessage + HasUnread bool +} + +// ChatInbox organizes messages by thread for a user +type ChatInbox struct { + Threads map[string]*ChatThread // threadID -> Thread + UnreadCount int // Cached unread message count +} + +// stored direct messages +var chatMessages []*ChatMessage +var chatInboxes map[string]*ChatInbox +var chatMessagesMutex sync.RWMutex + // saveRoomMessages persists room messages to disk func saveRoomMessages(roomID string, messages []RoomMessage) { filename := "room_" + strings.ReplaceAll(roomID, "/", "_") + ".json" @@ -1023,6 +1056,9 @@ func Load() { } } + // Load chat messages + loadChatMessages() + // Subscribe to summary generation requests summaryRequestSub := data.Subscribe(data.EventGenerateSummary) go func() { @@ -1234,6 +1270,14 @@ func generateSummaries() { } func Handler(w http.ResponseWriter, r *http.Request) { + // Check mode parameter - "messages" for direct messaging, default is AI chat + mode := r.URL.Query().Get("mode") + + if mode == "messages" { + handleMessagesMode(w, r) + return + } + // Check if this is a room-based chat (e.g., /chat?id=post_123) roomID := r.URL.Query().Get("id") @@ -1348,6 +1392,13 @@ func handleGetChat(w http.ResponseWriter, r *http.Request, roomID string) { roomJSON, _ := json.Marshal(roomData) tmpl := app.RenderHTMLForRequest("Chat", "Chat with AI", fmt.Sprintf(Template, topicTabs), r) + + // Add a link to messages mode + messagesLink := `
+

💬 New: Direct Messaging - Send messages to other users or chat with @micro (AI assistant)

+
` + + tmpl = strings.Replace(tmpl, `
`, messagesLink+`
`, 1) tmpl = strings.Replace(tmpl, "", fmt.Sprintf(``, summariesJSON, roomJSON), 1) w.Write([]byte(tmpl)) @@ -1659,3 +1710,255 @@ func cleanupIdleRooms() { } } } + +// Chat Messaging Functions (Direct Messages) + +// loadChatMessages loads chat messages from disk +func loadChatMessages() { +b, err := data.LoadFile("chat_messages.json") +if err != nil { +chatMessages = []*ChatMessage{} +chatInboxes = make(map[string]*ChatInbox) +return +} + +if err := json.Unmarshal(b, &chatMessages); err != nil { +chatMessages = []*ChatMessage{} +chatInboxes = make(map[string]*ChatInbox) +return +} + +app.Log("chat", "Loaded %d chat messages", len(chatMessages)) +fixChatThreading() +rebuildChatInboxes() +} + +// saveChatMessages saves chat messages to disk +func saveChatMessages() error { +chatMessagesMutex.RLock() +defer chatMessagesMutex.RUnlock() + +b, err := json.Marshal(chatMessages) +if err != nil { +return err +} + +return data.SaveFile("chat_messages.json", string(b)) +} + +// fixChatThreading repairs broken threading relationships +func fixChatThreading() { +fixed := 0 + +for _, msg := range chatMessages { +if msg.ReplyTo == "" { +continue +} + +if getChatMessageUnlocked(msg.ReplyTo) == nil { +app.Log("chat", "Message %s has missing parent %s - marking as root", msg.ID, msg.ReplyTo) +msg.ReplyTo = "" +fixed++ +} +} + +for _, msg := range chatMessages { +threadID := computeChatThreadID(msg) +if msg.ThreadID != threadID { +msg.ThreadID = threadID +fixed++ +} +} + +if fixed > 0 { +app.Log("chat", "Fixed threading for %d messages", fixed) +saveChatMessages() +} +} + +// computeChatThreadID walks up the chain to find the root message ID +func computeChatThreadID(msg *ChatMessage) string { +if msg.ReplyTo == "" { +return msg.ID +} + +visited := make(map[string]bool) +current := msg +for current.ReplyTo != "" && !visited[current.ID] { +visited[current.ID] = true +parent := getChatMessageUnlocked(current.ReplyTo) +if parent == nil { +return current.ID +} +current = parent +} + +return current.ID +} + +// getChatMessageUnlocked returns message by ID (caller must hold lock) +func getChatMessageUnlocked(id string) *ChatMessage { +for _, m := range chatMessages { +if m.ID == id { +return m +} +} +return nil +} + +// rebuildChatInboxes builds inbox structures from messages +func rebuildChatInboxes() { +chatInboxes = make(map[string]*ChatInbox) + +for _, msg := range chatMessages { +// Add to sender's inbox (sent messages) +if _, exists := chatInboxes[msg.FromID]; !exists { +chatInboxes[msg.FromID] = &ChatInbox{ +Threads: make(map[string]*ChatThread), +UnreadCount: 0, +} +} + +// Add to recipient's inbox (received messages) +if _, exists := chatInboxes[msg.ToID]; !exists { +chatInboxes[msg.ToID] = &ChatInbox{ +Threads: make(map[string]*ChatThread), +UnreadCount: 0, +} +} + +addChatMessageToInbox(chatInboxes[msg.FromID], msg, msg.FromID) +addChatMessageToInbox(chatInboxes[msg.ToID], msg, msg.ToID) +} +} + +// addChatMessageToInbox adds a message to an inbox +func addChatMessageToInbox(inbox *ChatInbox, msg *ChatMessage, userID string) { +threadID := msg.ThreadID +if threadID == "" { +threadID = computeChatThreadID(msg) +if threadID == "" { +threadID = msg.ID +} +} + +isUnread := !msg.Read && msg.ToID == userID +thread := inbox.Threads[threadID] +if thread == nil { +rootMsg := getChatMessageUnlocked(threadID) +if rootMsg == nil { +rootMsg = msg +} +thread = &ChatThread{ +Root: rootMsg, +Messages: []*ChatMessage{msg}, +Latest: msg, +HasUnread: isUnread, +} +inbox.Threads[threadID] = thread +if isUnread { +inbox.UnreadCount++ +} +} else { +thread.Messages = append(thread.Messages, msg) +if msg.CreatedAt.After(thread.Latest.CreatedAt) { +thread.Latest = msg +} +if isUnread { +thread.HasUnread = true +inbox.UnreadCount++ +} +} +} + +// SendChatMessage creates and stores a new chat message +func SendChatMessage(fromName, fromID, toName, toID, body, replyTo string) error { +chatMessagesMutex.Lock() +defer chatMessagesMutex.Unlock() + +msg := &ChatMessage{ +ID: fmt.Sprintf("%d", time.Now().UnixNano()), +From: fromName, +FromID: fromID, +To: toName, +ToID: toID, +Body: body, +Read: false, +ReplyTo: replyTo, +CreatedAt: time.Now(), +} + +// Compute thread ID +if replyTo != "" { +parent := getChatMessageUnlocked(replyTo) +if parent != nil { +msg.ThreadID = computeChatThreadID(parent) +} else { +msg.ThreadID = msg.ID +} +} else { +msg.ThreadID = msg.ID +} + +chatMessages = append(chatMessages, msg) + +// Update inboxes +if chatInboxes[fromID] == nil { +chatInboxes[fromID] = &ChatInbox{ +Threads: make(map[string]*ChatThread), +UnreadCount: 0, +} +} +if chatInboxes[toID] == nil { +chatInboxes[toID] = &ChatInbox{ +Threads: make(map[string]*ChatThread), +UnreadCount: 0, +} +} + +addChatMessageToInbox(chatInboxes[fromID], msg, fromID) +addChatMessageToInbox(chatInboxes[toID], msg, toID) + +app.Log("chat", "Sent message from %s to %s", fromName, toName) + +return saveChatMessages() +} + +// GetChatInbox returns the inbox for a user +func GetChatInbox(userID string) *ChatInbox { +chatMessagesMutex.RLock() +defer chatMessagesMutex.RUnlock() + +inbox := chatInboxes[userID] +if inbox == nil { +return &ChatInbox{ +Threads: make(map[string]*ChatThread), +UnreadCount: 0, +} +} +return inbox +} + +// MarkChatMessageAsRead marks a message as read +func MarkChatMessageAsRead(msgID, userID string) error { +chatMessagesMutex.Lock() +defer chatMessagesMutex.Unlock() + +for _, msg := range chatMessages { +if msg.ID == msgID && msg.ToID == userID && !msg.Read { +msg.Read = true + +// Update inbox unread count +if inbox := chatInboxes[userID]; inbox != nil { +inbox.UnreadCount-- +if inbox.UnreadCount < 0 { +inbox.UnreadCount = 0 +} +} + +return saveChatMessages() +} +} + +return nil +} diff --git a/chat/messages.go b/chat/messages.go new file mode 100644 index 00000000..23beba16 --- /dev/null +++ b/chat/messages.go @@ -0,0 +1,307 @@ +package chat + +import ( + "fmt" + "html" + "net/http" + "sort" + "strings" + + "mu/app" + "mu/auth" +) + +// handleMessagesMode handles direct messaging UI and logic +func handleMessagesMode(w http.ResponseWriter, r *http.Request) { + _, acc, err := auth.RequireSession(r) + if err != nil { + app.Unauthorized(w, r) + return + } + + // Handle POST - send message + if r.Method == "POST" { + if err := r.ParseForm(); err != nil { + app.BadRequest(w, r, "Failed to parse form") + return + } + + to := strings.TrimSpace(r.FormValue("to")) + body := strings.TrimSpace(r.FormValue("body")) + replyTo := strings.TrimSpace(r.FormValue("reply_to")) + + if to == "" || body == "" { + http.Error(w, "Recipient and message are required", http.StatusBadRequest) + return + } + + // Check if recipient is @micro (AI assistant) + if to == "micro" || to == "@micro" { + // This is handled by the AI chat - redirect + http.Redirect(w, r, "/chat", http.StatusSeeOther) + return + } + + // Look up recipient + toAcc, err := auth.GetAccountByName(to) + if err != nil { + http.Error(w, "Recipient not found", http.StatusNotFound) + return + } + + // Send message + if err := SendChatMessage(acc.Name, acc.ID, toAcc.Name, toAcc.ID, body, replyTo); err != nil { + http.Error(w, "Failed to send message", http.StatusInternalServerError) + return + } + + // Redirect to thread if replying, otherwise to inbox + threadID := r.URL.Query().Get("id") + if threadID != "" { + http.Redirect(w, r, "/chat?mode=messages&id="+threadID, http.StatusSeeOther) + } else if replyTo != "" { + chatMessagesMutex.RLock() + parentMsg := getChatMessageUnlocked(replyTo) + chatMessagesMutex.RUnlock() + if parentMsg != nil { + http.Redirect(w, r, "/chat?mode=messages&id="+parentMsg.ThreadID, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/chat?mode=messages", http.StatusSeeOther) + } + } else { + http.Redirect(w, r, "/chat?mode=messages", http.StatusSeeOther) + } + return + } + + // Handle GET - show inbox or thread + msgID := r.URL.Query().Get("id") + compose := r.URL.Query().Get("compose") + + if msgID != "" { + // Show thread + renderChatThread(w, r, msgID, acc) + return + } + + if compose != "" { + // Show compose form + renderChatCompose(w, r, acc) + return + } + + // Show inbox + renderChatInbox(w, r, acc) +} + +// renderChatInbox renders the chat inbox with conversations +func renderChatInbox(w http.ResponseWriter, r *http.Request, acc *auth.Account) { + inbox := GetChatInbox(acc.ID) + + // Get all threads and sort by latest message time + type threadInfo struct { + thread *ChatThread + id string + } + var threads []threadInfo + for id, thread := range inbox.Threads { + threads = append(threads, threadInfo{thread: thread, id: id}) + } + + sort.Slice(threads, func(i, j int) bool { + return threads[i].thread.Latest.CreatedAt.After(threads[j].thread.Latest.CreatedAt) + }) + + // Render thread previews + var items []string + for _, t := range threads { + thread := t.thread + root := thread.Root + latest := thread.Latest + + // Determine who we're chatting with + var otherUser string + if root.FromID == acc.ID { + otherUser = root.To + } else { + otherUser = root.From + } + + // Build preview + unreadMarker := "" + if thread.HasUnread { + unreadMarker = `` + } + + preview := latest.Body + if len(preview) > 100 { + preview = preview[:100] + "..." + } + preview = html.EscapeString(preview) + + timeAgo := app.TimeAgo(latest.CreatedAt) + + item := fmt.Sprintf(` +
+
+ %s%s +
+
+
%s
+ %s +
+
+ `, root.ID, html.EscapeString(otherUser), unreadMarker, preview, timeAgo) + items = append(items, item) + } + + content := ` +
+ New Chat + AI Chat +
+ ` + + if len(items) == 0 { + content += `
+

No conversations yet.

+

Start a new chat or try chatting with @micro (AI assistant)

+
` + } else { + content += `
` + strings.Join(items, "\n") + `
` + } + + htmlContent := app.RenderHTMLForRequest("Chat Messages", "Direct messages", content, r) + w.Write([]byte(htmlContent)) +} + +// renderChatCompose renders the new message compose form +func renderChatCompose(w http.ResponseWriter, r *http.Request, acc *auth.Account) { + to := r.URL.Query().Get("to") + + content := fmt.Sprintf(` +
+ ← Back to Inbox +
+
+

New Chat

+
+
+ + + Tip: Type 'micro' to chat with the AI assistant +
+
+ + +
+ +
+
+ `, html.EscapeString(to)) + + htmlContent := app.RenderHTMLForRequest("New Chat", "Compose message", content, r) + w.Write([]byte(htmlContent)) +} + +// renderChatThread renders a conversation thread +func renderChatThread(w http.ResponseWriter, r *http.Request, threadID string, acc *auth.Account) { + chatMessagesMutex.RLock() + + // Get all messages in thread + var threadMessages []*ChatMessage + for _, msg := range chatMessages { + if msg.ThreadID == threadID && (msg.FromID == acc.ID || msg.ToID == acc.ID) { + threadMessages = append(threadMessages, msg) + } + } + + if len(threadMessages) == 0 { + chatMessagesMutex.RUnlock() + http.Error(w, "Thread not found", http.StatusNotFound) + return + } + + // Sort by created time + sort.Slice(threadMessages, func(i, j int) bool { + return threadMessages[i].CreatedAt.Before(threadMessages[j].CreatedAt) + }) + + // Determine who we're chatting with + firstMsg := threadMessages[0] + var otherUser string + var otherUserID string + if firstMsg.FromID == acc.ID { + otherUser = firstMsg.To + otherUserID = firstMsg.ToID + } else { + otherUser = firstMsg.From + otherUserID = firstMsg.FromID + } + + chatMessagesMutex.RUnlock() + + // Mark all unread messages as read + for _, msg := range threadMessages { + if msg.ToID == acc.ID && !msg.Read { + MarkChatMessageAsRead(msg.ID, acc.ID) + } + } + + // Render messages + var messageHTML []string + for _, msg := range threadMessages { + isSent := msg.FromID == acc.ID + + sender := msg.From + if isSent { + sender = "You" + } + + body := html.EscapeString(msg.Body) + body = strings.ReplaceAll(body, "\n", "
") + + timeStr := msg.CreatedAt.Format("Jan 2, 3:04 PM") + + borderColor := "#28a745" + if isSent { + borderColor = "#007bff" + } + + msgHTML := fmt.Sprintf(` +
+
%s
+
%s
+
%s
+
+ `, borderColor, borderColor, sender, body, timeStr) + messageHTML = append(messageHTML, msgHTML) + } + + content := fmt.Sprintf(` +
+ ← Back to Inbox +

Chat with %s

+
+
+ %s +
+
+
+ + +
+ + +
+ +
+
+ `, html.EscapeString(otherUser), strings.Join(messageHTML, "\n"), threadID, otherUserID, threadMessages[len(threadMessages)-1].ID) + + htmlContent := app.RenderHTMLForRequest("Chat Thread", "Conversation with "+otherUser, content, r) + w.Write([]byte(htmlContent)) +} diff --git a/chat/xmpp.go b/chat/xmpp.go new file mode 100644 index 00000000..0ac03bb1 --- /dev/null +++ b/chat/xmpp.go @@ -0,0 +1,1141 @@ +package chat + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "log" + "net" + "os" + "strings" + "sync" + "time" + + "mu/app" + "mu/auth" + "mu/data" + + "golang.org/x/crypto/bcrypt" +) + +// XMPP server implementation for chat federation +// Similar to mail/SMTP, this provides decentralized chat capability +// Implements core XMPP (RFC 6120, 6121, 6122) +// With full S2S federation, TLS, MUC, and offline messages + +const ( + xmppNamespace = "jabber:client" + xmppStreamNamespace = "http://etherx.jabber.org/streams" + xmppSASLNamespace = "urn:ietf:params:xml:ns:xmpp-sasl" + xmppBindNamespace = "urn:ietf:params:xml:ns:xmpp-bind" + xmppTLSNamespace = "urn:ietf:params:xml:ns:xmpp-tls" + xmppMUCNamespace = "http://jabber.org/protocol/muc" + xmppS2SNamespace = "jabber:server" +) + +// XMPPServer represents the XMPP server +type XMPPServer struct { + Domain string + Port string + S2SPort string + listener net.Listener + s2sListener net.Listener + sessions map[string]*XMPPSession + s2sSessions map[string]*S2SSession // domain -> S2S session + rooms map[string]*MUCRoom + tlsConfig *tls.Config + mutex sync.RWMutex + ctx context.Context + cancel context.CancelFunc +} + +// XMPPSession represents a client connection +type XMPPSession struct { + conn net.Conn + jid string // Full JID (user@domain/resource) + username string + resource string + domain string + authorized bool + encrypted bool + encoder *xml.Encoder + decoder *xml.Decoder + mutex sync.Mutex + offlineQueue []*Message // Offline message queue +} + +// S2SSession represents a server-to-server connection +type S2SSession struct { + conn net.Conn + domain string + remoteDomain string + authenticated bool + encrypted bool + encoder *xml.Encoder + decoder *xml.Decoder + mutex sync.Mutex + outbound bool // true if we initiated the connection +} + +// MUCRoom represents a multi-user chat room +type MUCRoom struct { + JID string + Name string + Subject string + Occupants map[string]*MUCOccupant + Persistent bool + CreatedAt time.Time + mutex sync.RWMutex +} + +// MUCOccupant represents a user in a MUC room +type MUCOccupant struct { + JID string + Nick string + Role string // moderator, participant, visitor + Affiliation string // owner, admin, member, none + session *XMPPSession +} + +// OfflineMessage represents a stored message for offline delivery +type OfflineMessage struct { + ID string `json:"id"` + From string `json:"from"` + To string `json:"to"` + Body string `json:"body"` + Timestamp time.Time `json:"timestamp"` + Type string `json:"type"` +} + +// XMPP stream elements +type StreamStart struct { + XMLName xml.Name `xml:"http://etherx.jabber.org/streams stream"` + From string `xml:"from,attr,omitempty"` + To string `xml:"to,attr,omitempty"` + ID string `xml:"id,attr,omitempty"` + Version string `xml:"version,attr,omitempty"` + Lang string `xml:"xml:lang,attr,omitempty"` +} + +type StreamFeatures struct { + XMLName xml.Name `xml:"stream:features"` + StartTLS *struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"` + Required *struct{} `xml:"required,omitempty"` + } `xml:"starttls,omitempty"` + Mechanisms []string `xml:"mechanisms>mechanism,omitempty"` + Bind *struct{} `xml:"bind,omitempty"` + Session *struct{} `xml:"session,omitempty"` +} + +type TLSProceed struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls proceed"` +} + +type TLSFailure struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls failure"` +} + +type StartTLS struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"` +} + +type SASLAuth struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl auth"` + Mechanism string `xml:"mechanism,attr"` + Value string `xml:",chardata"` +} + +type SASLSuccess struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl success"` +} + +type SASLFailure struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl failure"` + Reason string `xml:",innerxml"` +} + +type IQBind struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-bind bind"` + Resource string `xml:"resource,omitempty"` + JID string `xml:"jid,omitempty"` +} + +type IQ struct { + XMLName xml.Name `xml:"iq"` + Type string `xml:"type,attr"` + ID string `xml:"id,attr,omitempty"` + From string `xml:"from,attr,omitempty"` + To string `xml:"to,attr,omitempty"` + Bind *IQBind `xml:"bind,omitempty"` + Error *struct { + Type string `xml:"type,attr"` + Text string `xml:",innerxml"` + } `xml:"error,omitempty"` +} + +type Message struct { + XMLName xml.Name `xml:"message"` + Type string `xml:"type,attr,omitempty"` + From string `xml:"from,attr,omitempty"` + To string `xml:"to,attr,omitempty"` + ID string `xml:"id,attr,omitempty"` + Body string `xml:"body,omitempty"` +} + +type Presence struct { + XMLName xml.Name `xml:"presence"` + Type string `xml:"type,attr,omitempty"` + From string `xml:"from,attr,omitempty"` + To string `xml:"to,attr,omitempty"` + Show string `xml:"show,omitempty"` + Status string `xml:"status,omitempty"` +} + +// NewXMPPServer creates a new XMPP server instance +func NewXMPPServer(domain, port, s2sPort string) *XMPPServer { + ctx, cancel := context.WithCancel(context.Background()) + + // Load TLS configuration if certificates are available + tlsConfig := loadTLSConfig(domain) + + return &XMPPServer{ + Domain: domain, + Port: port, + S2SPort: s2sPort, + sessions: make(map[string]*XMPPSession), + s2sSessions: make(map[string]*S2SSession), + rooms: make(map[string]*MUCRoom), + tlsConfig: tlsConfig, + ctx: ctx, + cancel: cancel, + } +} + +// loadTLSConfig loads TLS certificates for XMPP +func loadTLSConfig(domain string) *tls.Config { + certFile := os.Getenv("XMPP_CERT_FILE") + keyFile := os.Getenv("XMPP_KEY_FILE") + + if certFile == "" || keyFile == "" { + // Try default paths + certFile = fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain) + keyFile = fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", domain) + } + + // Check if certificate files exist + if _, err := os.Stat(certFile); os.IsNotExist(err) { + app.Log("xmpp", "TLS certificate not found at %s - TLS disabled", certFile) + return nil + } + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + app.Log("xmpp", "Failed to load TLS certificates: %v - TLS disabled", err) + return nil + } + + app.Log("xmpp", "TLS certificates loaded successfully") + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: domain, + MinVersion: tls.VersionTLS12, + } +} + +// Start begins listening for XMPP connections +func (s *XMPPServer) Start() error { + // Start C2S (Client-to-Server) listener + addr := ":" + s.Port + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to start XMPP C2S server: %v", err) + } + + s.listener = listener + app.Log("xmpp", "XMPP C2S server listening on %s (domain: %s)", addr, s.Domain) + + // Start S2S (Server-to-Server) listener if port is configured + if s.S2SPort != "" { + s2sAddr := ":" + s.S2SPort + s2sListener, err := net.Listen("tcp", s2sAddr) + if err != nil { + app.Log("xmpp", "Failed to start S2S listener: %v", err) + } else { + s.s2sListener = s2sListener + app.Log("xmpp", "XMPP S2S server listening on %s", s2sAddr) + go s.acceptS2SConnections() + } + } + + if s.tlsConfig != nil { + app.Log("xmpp", "STARTTLS enabled") + } else { + app.Log("xmpp", "WARNING: TLS not configured - connections will be unencrypted") + } + + // Accept C2S connections + go s.acceptConnections() + + // Start offline message delivery worker + go s.processOfflineMessages() + + return nil +} + +// acceptConnections handles incoming connections +func (s *XMPPServer) acceptConnections() { + for { + select { + case <-s.ctx.Done(): + return + default: + conn, err := s.listener.Accept() + if err != nil { + select { + case <-s.ctx.Done(): + return + default: + app.Log("xmpp", "Error accepting connection: %v", err) + continue + } + } + + // Handle each connection in a goroutine + go s.handleConnection(conn) + } + } +} + +// acceptS2SConnections handles incoming server-to-server connections +func (s *XMPPServer) acceptS2SConnections() { + for { + select { + case <-s.ctx.Done(): + return + default: + conn, err := s.s2sListener.Accept() + if err != nil { + select { + case <-s.ctx.Done(): + return + default: + app.Log("xmpp", "Error accepting S2S connection: %v", err) + continue + } + } + + // Handle each S2S connection in a goroutine + go s.handleS2SConnection(conn, false) + } + } +} + +// handleConnection processes a single XMPP client connection +func (s *XMPPServer) handleConnection(conn net.Conn) { + defer conn.Close() + + session := &XMPPSession{ + conn: conn, + domain: s.Domain, + encoder: xml.NewEncoder(conn), + decoder: xml.NewDecoder(conn), + } + + remoteAddr := conn.RemoteAddr().String() + app.Log("xmpp", "New connection from %s", remoteAddr) + + // Initial stream negotiation + if err := s.handleStreamNegotiation(session); err != nil { + app.Log("xmpp", "Stream negotiation failed: %v", err) + return + } + + // Main stanza processing loop + s.handleStanzas(session) +} + +// handleStreamNegotiation performs initial XMPP stream setup +func (s *XMPPServer) handleStreamNegotiation(session *XMPPSession) error { + // Read opening stream tag + var streamStart StreamStart + if err := session.decoder.Decode(&streamStart); err != nil { + return fmt.Errorf("failed to read stream start: %v", err) + } + + // Send stream response + streamID := generateStreamID() + response := fmt.Sprintf(` +`, s.Domain, streamID) + + if _, err := session.conn.Write([]byte(response)); err != nil { + return fmt.Errorf("failed to send stream header: %v", err) + } + + // Send stream features + features := StreamFeatures{} + + // Offer STARTTLS if TLS is configured and not yet encrypted + if s.tlsConfig != nil && !session.encrypted { + features.StartTLS = &struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"` + Required *struct{} `xml:"required,omitempty"` + }{ + Required: &struct{}{}, // Make TLS required + } + } + + // Offer SASL mechanisms only after TLS or if TLS not available + if session.encrypted || s.tlsConfig == nil { + features.Mechanisms = []string{"PLAIN"} + } + + // Offer resource binding and session after authentication + if session.authorized { + features.Bind = &struct{}{} + features.Session = &struct{}{} + } + + if err := session.encoder.Encode(&features); err != nil { + return fmt.Errorf("failed to send features: %v", err) + } + + return nil +} + +// handleStanzas processes incoming XMPP stanzas +func (s *XMPPServer) handleStanzas(session *XMPPSession) { + for { + // Read next token + token, err := session.decoder.Token() + if err != nil { + if err != io.EOF { + app.Log("xmpp", "Error reading token: %v", err) + } + return + } + + switch t := token.(type) { + case xml.StartElement: + switch t.Name.Local { + case "starttls": + if err := s.handleStartTLS(session); err != nil { + app.Log("xmpp", "STARTTLS failed: %v", err) + return + } + // After TLS upgrade, restart stream negotiation + if err := s.handleStreamNegotiation(session); err != nil { + app.Log("xmpp", "Stream renegotiation after TLS failed: %v", err) + return + } + case "auth": + s.handleAuth(session) + case "iq": + s.handleIQ(session, t) + case "message": + s.handleMessage(session, t) + case "presence": + s.handlePresence(session, t) + } + case xml.EndElement: + if t.Name.Local == "stream" { + return + } + } + } +} + +// handleAuth processes SASL authentication +func (s *XMPPServer) handleAuth(session *XMPPSession) { + var authStanza SASLAuth + if err := session.decoder.DecodeElement(&authStanza, nil); err != nil { + app.Log("xmpp", "Failed to decode auth: %v", err) + if err := session.encoder.Encode(&SASLFailure{Reason: "malformed-request"}); err != nil { + app.Log("xmpp", "Failed to send auth failure: %v", err) + } + return + } + + // For PLAIN mechanism, decode credentials + if authStanza.Mechanism == "PLAIN" { + // PLAIN format: \0username\0password (base64 encoded) + // Decode the base64 auth value + decoded, err := base64.StdEncoding.DecodeString(authStanza.Value) + if err != nil { + app.Log("xmpp", "Failed to decode auth credentials: %v", err) + if err := session.encoder.Encode(&SASLFailure{Reason: "malformed-request"}); err != nil { + app.Log("xmpp", "Failed to send auth failure: %v", err) + } + return + } + + // Parse PLAIN SASL format: [authzid]\0username\0password + parts := strings.Split(string(decoded), "\x00") + if len(parts) < 3 { + app.Log("xmpp", "Invalid PLAIN SASL format") + if err := session.encoder.Encode(&SASLFailure{Reason: "invalid-authzid"}); err != nil { + app.Log("xmpp", "Failed to send auth failure: %v", err) + } + return + } + + username := parts[1] + password := parts[2] + + // Verify credentials against auth system + acc, err := auth.GetAccountByName(username) + if err != nil { + app.Log("xmpp", "Authentication failed for user %s: user not found", username) + if err := session.encoder.Encode(&SASLFailure{Reason: "not-authorized"}); err != nil { + app.Log("xmpp", "Failed to send auth failure: %v", err) + } + return + } + + // Verify password using bcrypt + if err := bcrypt.CompareHashAndPassword([]byte(acc.Secret), []byte(password)); err != nil { + app.Log("xmpp", "Authentication failed for user %s: invalid password", username) + if err := session.encoder.Encode(&SASLFailure{Reason: "not-authorized"}); err != nil { + app.Log("xmpp", "Failed to send auth failure: %v", err) + } + return + } + + // Authentication successful + session.authorized = true + session.username = username + app.Log("xmpp", "User %s authenticated successfully", username) + + // Send success + if err := session.encoder.Encode(&SASLSuccess{}); err != nil { + app.Log("xmpp", "Failed to send auth success: %v", err) + return + } + + // Client will restart stream after successful auth + } else { + if err := session.encoder.Encode(&SASLFailure{Reason: "invalid-mechanism"}); err != nil { + app.Log("xmpp", "Failed to send auth failure: %v", err) + } + } +} + +// handleIQ processes IQ (Info/Query) stanzas +func (s *XMPPServer) handleIQ(session *XMPPSession, start xml.StartElement) { + var iq IQ + if err := session.decoder.DecodeElement(&iq, &start); err != nil { + app.Log("xmpp", "Failed to decode IQ: %v", err) + return + } + + // Handle resource binding + if iq.Type == "set" && iq.Bind != nil { + resource := iq.Bind.Resource + if resource == "" { + resource = "mu-" + generateStreamID()[:8] + } + + session.resource = resource + session.jid = fmt.Sprintf("%s@%s/%s", session.username, s.Domain, resource) + + // Store session + s.mutex.Lock() + s.sessions[session.jid] = session + s.mutex.Unlock() + + // Send bind result + result := IQ{ + Type: "result", + ID: iq.ID, + Bind: &IQBind{ + JID: session.jid, + }, + } + + if err := session.encoder.Encode(&result); err != nil { + app.Log("xmpp", "Failed to send bind result: %v", err) + } + + app.Log("xmpp", "User bound to JID: %s", session.jid) + + // Deliver any offline messages + go s.deliverOfflineMessages(session) + } +} + +// handleMessage processes message stanzas +func (s *XMPPServer) handleMessage(session *XMPPSession, start xml.StartElement) { + var msg Message + if err := session.decoder.DecodeElement(&msg, &start); err != nil { + app.Log("xmpp", "Failed to decode message: %v", err) + return + } + + // Set from if not already set + if msg.From == "" { + msg.From = session.jid + } + + app.Log("xmpp", "Message from %s to %s: %s", msg.From, msg.To, msg.Body) + + // Route message to recipient + if msg.To != "" { + s.routeMessage(&msg) + } +} + +// handlePresence processes presence stanzas +func (s *XMPPServer) handlePresence(session *XMPPSession, start xml.StartElement) { + var pres Presence + if err := session.decoder.DecodeElement(&pres, &start); err != nil { + app.Log("xmpp", "Failed to decode presence: %v", err) + return + } + + if pres.From == "" { + pres.From = session.jid + } + + app.Log("xmpp", "Presence from %s: %s", pres.From, pres.Type) + + // Update user presence in auth system + if session.username != "" { + if account, err := auth.GetAccountByName(session.username); err == nil { + auth.UpdatePresence(account.ID) + } + } + + // Broadcast presence to other sessions + s.broadcastPresence(&pres) +} + +// routeMessage delivers a message to the recipient +func (s *XMPPServer) routeMessage(msg *Message) { + // Extract recipient JID + recipientJID := msg.To + + // Check if recipient is local or remote + parts := strings.Split(recipientJID, "@") + if len(parts) < 2 { + app.Log("xmpp", "Invalid recipient JID: %s", recipientJID) + return + } + + domain := strings.Split(parts[1], "/")[0] + + if domain == s.Domain { + // Local delivery + username := parts[0] + + // Try to find online session for this user + s.mutex.RLock() + var targetSession *XMPPSession + for jid, session := range s.sessions { + if strings.HasPrefix(jid, username+"@") { + targetSession = session + break + } + } + s.mutex.RUnlock() + + if targetSession != nil { + // User is online - deliver immediately + targetSession.mutex.Lock() + defer targetSession.mutex.Unlock() + + if err := targetSession.encoder.Encode(msg); err != nil { + app.Log("xmpp", "Failed to deliver message: %v", err) + // Save as offline if immediate delivery fails + s.saveOfflineMessage(msg) + } else { + app.Log("xmpp", "Message delivered to %s", recipientJID) + } + } else { + // User is offline - store message for later delivery + app.Log("xmpp", "User %s offline, storing message", username) + s.saveOfflineMessage(msg) + } + } else { + // Remote delivery via S2S (Server-to-Server) + app.Log("xmpp", "Routing message to remote domain: %s", domain) + + // Try to get or establish S2S connection + s2sSession, err := s.dialS2S(domain) + if err != nil { + app.Log("xmpp", "Failed to establish S2S connection to %s: %v", domain, err) + return + } + + // Send message over S2S connection + s2sSession.mutex.Lock() + defer s2sSession.mutex.Unlock() + + if err := s2sSession.encoder.Encode(msg); err != nil { + app.Log("xmpp", "Failed to send S2S message to %s: %v", domain, err) + } else { + app.Log("xmpp", "Message relayed to remote domain: %s", domain) + } + } +} + +// broadcastPresence sends presence to all sessions +func (s *XMPPServer) broadcastPresence(pres *Presence) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + for jid, session := range s.sessions { + if jid != pres.From { + session.mutex.Lock() + if err := session.encoder.Encode(pres); err != nil { + app.Log("xmpp", "Failed to broadcast presence to %s: %v", jid, err) + } + session.mutex.Unlock() + } + } +} + +// generateStreamID creates a unique stream identifier +func generateStreamID() string { + b := make([]byte, 16) + rand.Read(b) + return fmt.Sprintf("%x", b) +} + +// handleStartTLS upgrades connection to TLS +func (s *XMPPServer) handleStartTLS(session *XMPPSession) error { + if s.tlsConfig == nil { + if err := session.encoder.Encode(&TLSFailure{}); err != nil { + return fmt.Errorf("failed to send TLS failure: %v", err) + } + return fmt.Errorf("TLS not configured") + } + + // Send TLS proceed + if err := session.encoder.Encode(&TLSProceed{}); err != nil { + return fmt.Errorf("failed to send TLS proceed: %v", err) + } + + // Upgrade connection to TLS + tlsConn := tls.Server(session.conn, s.tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return fmt.Errorf("TLS handshake failed: %v", err) + } + + // Update session connection and IO + session.conn = tlsConn + session.encrypted = true + session.encoder = xml.NewEncoder(tlsConn) + session.decoder = xml.NewDecoder(tlsConn) + + app.Log("xmpp", "TLS negotiation successful") + return nil +} + +// handleS2SConnection processes a server-to-server connection +func (s *XMPPServer) handleS2SConnection(conn net.Conn, outbound bool) { + defer conn.Close() + + s2sSession := &S2SSession{ + conn: conn, + encoder: xml.NewEncoder(conn), + decoder: xml.NewDecoder(conn), + outbound: outbound, + } + + remoteAddr := conn.RemoteAddr().String() + app.Log("xmpp", "New S2S connection from %s (outbound: %v)", remoteAddr, outbound) + + // S2S stream negotiation (simplified - full implementation would use dialback) + // For now, just log that we received an S2S connection + if !outbound { + // Inbound S2S connection + var streamStart StreamStart + if err := s2sSession.decoder.Decode(&streamStart); err != nil { + app.Log("xmpp", "S2S stream start failed: %v", err) + return + } + + s2sSession.remoteDomain = streamStart.From + + // Send stream response + streamID := generateStreamID() + response := fmt.Sprintf(` +`, + s.Domain, s2sSession.remoteDomain, streamID) + + if _, err := conn.Write([]byte(response)); err != nil { + app.Log("xmpp", "Failed to send S2S stream header: %v", err) + return + } + + // For now, mark as authenticated (in production, would do dialback) + s2sSession.authenticated = true + s2sSession.domain = s.Domain + + // Store S2S session + s.mutex.Lock() + s.s2sSessions[s2sSession.remoteDomain] = s2sSession + s.mutex.Unlock() + + app.Log("xmpp", "S2S session established with %s", s2sSession.remoteDomain) + + // Handle S2S stanzas (simplified) + s.handleS2SStanzas(s2sSession) + } +} + +// handleS2SStanzas processes S2S stanzas +func (s *XMPPServer) handleS2SStanzas(session *S2SSession) { + for { + token, err := session.decoder.Token() + if err != nil { + if err != io.EOF { + app.Log("xmpp", "S2S error reading token: %v", err) + } + return + } + + switch t := token.(type) { + case xml.StartElement: + switch t.Name.Local { + case "message": + var msg Message + if err := session.decoder.DecodeElement(&msg, &t); err != nil { + app.Log("xmpp", "Failed to decode S2S message: %v", err) + continue + } + // Route to local user + s.routeMessage(&msg) + } + case xml.EndElement: + if t.Name.Local == "stream" { + return + } + } + } +} + +// dialS2S creates an outbound S2S connection to a remote server +func (s *XMPPServer) dialS2S(domain string) (*S2SSession, error) { + // Check if we already have a connection + s.mutex.RLock() + existing, ok := s.s2sSessions[domain] + s.mutex.RUnlock() + + if ok && existing.authenticated { + return existing, nil + } + + // Lookup SRV record for xmpp-server + // For now, just try port 5269 + addr := domain + ":5269" + + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("failed to connect to %s: %v", domain, err) + } + + s2sSession := &S2SSession{ + conn: conn, + domain: s.Domain, + remoteDomain: domain, + encoder: xml.NewEncoder(conn), + decoder: xml.NewDecoder(conn), + outbound: true, + } + + // Send stream header + streamHeader := fmt.Sprintf(` +`, s.Domain, domain) + + if _, err := conn.Write([]byte(streamHeader)); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to send S2S stream header: %v", err) + } + + // For simplified implementation, mark as authenticated + // Full implementation would do dialback or SASL EXTERNAL + s2sSession.authenticated = true + + // Store session + s.mutex.Lock() + s.s2sSessions[domain] = s2sSession + s.mutex.Unlock() + + // Start handling stanzas in background + go s.handleS2SStanzas(s2sSession) + + app.Log("xmpp", "Outbound S2S connection established to %s", domain) + + return s2sSession, nil +} + +// saveOfflineMessage stores a message for later delivery +func (s *XMPPServer) saveOfflineMessage(msg *Message) { + // Extract username from JID + parts := strings.Split(msg.To, "@") + if len(parts) < 2 { + return + } + username := parts[0] + + offlineMsg := OfflineMessage{ + ID: generateStreamID(), + From: msg.From, + To: msg.To, + Body: msg.Body, + Timestamp: time.Now(), + Type: msg.Type, + } + + // Load existing offline messages + var messages []OfflineMessage + if b, err := data.LoadFile(fmt.Sprintf("xmpp_offline_%s.json", username)); err == nil { + json.Unmarshal(b, &messages) + } + + // Append new message + messages = append(messages, offlineMsg) + + // Save back + if b, err := json.Marshal(messages); err == nil { + data.SaveFile(fmt.Sprintf("xmpp_offline_%s.json", username), string(b)) + app.Log("xmpp", "Saved offline message for %s", username) + } +} + +// deliverOfflineMessages delivers stored offline messages to a user +func (s *XMPPServer) deliverOfflineMessages(session *XMPPSession) { + if session.username == "" { + return + } + + // Load offline messages + var messages []OfflineMessage + filename := fmt.Sprintf("xmpp_offline_%s.json", session.username) + if b, err := data.LoadFile(filename); err == nil { + if err := json.Unmarshal(b, &messages); err != nil { + return + } + } + + if len(messages) == 0 { + return + } + + app.Log("xmpp", "Delivering %d offline messages to %s", len(messages), session.jid) + + // Deliver each message + session.mutex.Lock() + defer session.mutex.Unlock() + + for _, offlineMsg := range messages { + msg := &Message{ + Type: offlineMsg.Type, + From: offlineMsg.From, + To: session.jid, + ID: offlineMsg.ID, + Body: offlineMsg.Body, + } + + if err := session.encoder.Encode(msg); err != nil { + app.Log("xmpp", "Failed to deliver offline message: %v", err) + } + } + + // Clear offline messages after delivery + data.SaveFile(filename, "[]") +} + +// processOfflineMessages worker that periodically delivers offline messages +func (s *XMPPServer) processOfflineMessages() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return + case <-ticker.C: + // Deliver offline messages to connected users + s.mutex.RLock() + for _, session := range s.sessions { + if session.authorized && session.username != "" { + s.deliverOfflineMessages(session) + } + } + s.mutex.RUnlock() + } + } +} + +// createRoom creates a new MUC room +func (s *XMPPServer) createRoom(roomJID, creator string) *MUCRoom { + s.mutex.Lock() + defer s.mutex.Unlock() + + if room, exists := s.rooms[roomJID]; exists { + return room + } + + room := &MUCRoom{ + JID: roomJID, + Name: strings.Split(roomJID, "@")[0], + Occupants: make(map[string]*MUCOccupant), + Persistent: true, + CreatedAt: time.Now(), + } + + s.rooms[roomJID] = room + app.Log("xmpp", "Created MUC room: %s", roomJID) + + return room +} + +// joinRoom adds a user to a MUC room +func (s *XMPPServer) joinRoom(roomJID, userJID, nick string, session *XMPPSession) error { + s.mutex.RLock() + room, exists := s.rooms[roomJID] + s.mutex.RUnlock() + + if !exists { + // Auto-create room + room = s.createRoom(roomJID, userJID) + } + + room.mutex.Lock() + defer room.mutex.Unlock() + + occupant := &MUCOccupant{ + JID: userJID, + Nick: nick, + Role: "participant", + Affiliation: "member", + session: session, + } + + room.Occupants[nick] = occupant + + // Broadcast presence to room + s.broadcastToRoom(room, &Presence{ + From: roomJID + "/" + nick, + To: userJID, + Type: "", + }) + + app.Log("xmpp", "User %s joined room %s as %s", userJID, roomJID, nick) + + return nil +} + +// broadcastToRoom sends a stanza to all occupants in a room +func (s *XMPPServer) broadcastToRoom(room *MUCRoom, stanza interface{}) { + room.mutex.RLock() + defer room.mutex.RUnlock() + + for _, occupant := range room.Occupants { + if occupant.session != nil { + occupant.session.mutex.Lock() + occupant.session.encoder.Encode(stanza) + occupant.session.mutex.Unlock() + } + } +} + +// Stop gracefully shuts down the XMPP server +func (s *XMPPServer) Stop() error { + app.Log("xmpp", "Shutting down XMPP server") + s.cancel() + + if s.listener != nil { + return s.listener.Close() + } + + return nil +} + +// Global XMPP server instance +var xmppServer *XMPPServer + +// StartXMPPServer initializes and starts the XMPP server +func StartXMPPServer() error { + // Get configuration from environment + domain := os.Getenv("XMPP_DOMAIN") + if domain == "" { + domain = "localhost" // Default domain + } + + port := os.Getenv("XMPP_PORT") + if port == "" { + port = "5222" // Standard XMPP client-to-server port + } + + s2sPort := os.Getenv("XMPP_S2S_PORT") + if s2sPort == "" { + s2sPort = "5269" // Standard XMPP server-to-server port + } + + // Create and start server + xmppServer = NewXMPPServer(domain, port, s2sPort) + + // Start in goroutine + go func() { + if err := xmppServer.Start(); err != nil { + log.Printf("XMPP server error: %v", err) + } + }() + + return nil +} + +// StartXMPPServerIfEnabled starts the XMPP server if configured +func StartXMPPServerIfEnabled() bool { + // Check if XMPP is enabled + enabled := os.Getenv("XMPP_ENABLED") + if enabled == "" || enabled == "false" || enabled == "0" { + app.Log("xmpp", "XMPP server disabled (set XMPP_ENABLED=true to enable)") + return false + } + + if err := StartXMPPServer(); err != nil { + app.Log("xmpp", "Failed to start XMPP server: %v", err) + return false + } + + return true +} + +// GetXMPPStatus returns the XMPP server status for health checks +func GetXMPPStatus() map[string]interface{} { + status := map[string]interface{}{ + "enabled": false, + } + + if xmppServer != nil { + xmppServer.mutex.RLock() + sessionCount := len(xmppServer.sessions) + s2sCount := len(xmppServer.s2sSessions) + roomCount := len(xmppServer.rooms) + xmppServer.mutex.RUnlock() + + status["enabled"] = true + status["domain"] = xmppServer.Domain + status["c2s_port"] = xmppServer.Port + status["s2s_port"] = xmppServer.S2SPort + status["sessions"] = sessionCount + status["s2s_connections"] = s2sCount + status["muc_rooms"] = roomCount + status["tls_enabled"] = xmppServer.tlsConfig != nil + } + + return status +} diff --git a/chat/xmpp_test.go b/chat/xmpp_test.go new file mode 100644 index 00000000..d70b764c --- /dev/null +++ b/chat/xmpp_test.go @@ -0,0 +1,104 @@ +package chat + +import ( + "testing" + "time" +) + +// TestNewXMPPServer tests server initialization +func TestNewXMPPServer(t *testing.T) { + server := NewXMPPServer("test.example.com", "5222") + + if server == nil { + t.Fatal("Expected server to be created, got nil") + } + + if server.Domain != "test.example.com" { + t.Errorf("Expected domain 'test.example.com', got '%s'", server.Domain) + } + + if server.Port != "5222" { + t.Errorf("Expected port '5222', got '%s'", server.Port) + } + + if server.sessions == nil { + t.Error("Expected sessions map to be initialized") + } + + if len(server.sessions) != 0 { + t.Errorf("Expected 0 sessions initially, got %d", len(server.sessions)) + } +} + +// TestGenerateStreamID tests stream ID generation +func TestGenerateStreamID(t *testing.T) { + id1 := generateStreamID() + if id1 == "" { + t.Error("Expected non-empty stream ID") + } + + // Wait a bit to ensure different timestamp + time.Sleep(1 * time.Millisecond) + + id2 := generateStreamID() + if id2 == "" { + t.Error("Expected non-empty stream ID") + } + + if id1 == id2 { + t.Error("Expected different stream IDs for different calls") + } +} + +// TestGetXMPPStatus tests status retrieval +func TestGetXMPPStatus(t *testing.T) { + // Test when server is nil (not started) + status := GetXMPPStatus() + + if status["enabled"] != false { + t.Error("Expected enabled to be false when server is nil") + } + + // Create a server instance + xmppServer = NewXMPPServer("test.example.com", "5222") + + status = GetXMPPStatus() + + if status["enabled"] != true { + t.Error("Expected enabled to be true when server exists") + } + + if status["domain"] != "test.example.com" { + t.Errorf("Expected domain 'test.example.com', got '%v'", status["domain"]) + } + + if status["port"] != "5222" { + t.Errorf("Expected port '5222', got '%v'", status["port"]) + } + + if status["sessions"] != 0 { + t.Errorf("Expected 0 sessions, got '%v'", status["sessions"]) + } + + // Clean up + xmppServer = nil +} + +// TestXMPPServerStop tests graceful shutdown +func TestXMPPServerStop(t *testing.T) { + server := NewXMPPServer("test.example.com", "5222") + + // Stop should not error even if listener is nil + err := server.Stop() + if err != nil { + t.Errorf("Expected no error on stop with nil listener, got %v", err) + } + + // Check that context is cancelled + select { + case <-server.ctx.Done(): + // Context cancelled as expected + case <-time.After(100 * time.Millisecond): + t.Error("Expected context to be cancelled after Stop()") + } +} diff --git a/go.mod b/go.mod index 5dda5a7e..1ea2f0b3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module mu -go 1.24.1 +go 1.25.0 require ( github.com/ProtonMail/go-crypto v1.3.0 @@ -8,15 +8,15 @@ require ( github.com/asim/quadtree v0.3.0 github.com/emersion/go-msgauth v0.7.0 github.com/emersion/go-smtp v0.24.0 - github.com/go-webauthn/webauthn v0.15.0 + github.com/go-webauthn/webauthn v0.16.0 github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mmcdole/gofeed v1.3.0 github.com/mrz1836/go-sanitize v1.5.3 github.com/piquette/finance-go v1.1.0 - golang.org/x/crypto v0.43.0 - golang.org/x/net v0.45.0 + golang.org/x/crypto v0.48.0 + golang.org/x/net v0.49.0 golang.org/x/sync v0.19.0 google.golang.org/api v0.243.0 modernc.org/sqlite v1.42.2 @@ -36,10 +36,10 @@ require ( github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/go-webauthn/x v0.1.26 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/google/go-tpm v0.9.6 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect @@ -59,8 +59,8 @@ require ( go.opentelemetry.io/otel/trace v1.36.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/grpc v1.73.0 // indirect google.golang.org/protobuf v1.36.6 // indirect diff --git a/go.sum b/go.sum index ca5e2b1b..12eba106 100644 --- a/go.sum +++ b/go.sum @@ -34,22 +34,24 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY= -github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A= -github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs= -github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.16.0 h1:A9BkfYIwWAMPSQCbM2HoWqo6JO5LFI8aqYAzo6nW7AY= +github.com/go-webauthn/webauthn v0.16.0/go.mod h1:hm9RS/JNYeUu3KqGbzqlnHClhDGCZzTZlABjathwnN0= +github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc= +github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA= -github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -111,15 +113,15 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= -golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -127,15 +129,15 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= google.golang.org/api v0.243.0 h1:sw+ESIJ4BVnlJcWu9S+p2Z6Qq1PjG77T8IJ1xtp4jZQ= google.golang.org/api v0.243.0/go.mod h1:GE4QtYfaybx1KmeHMdBnNnyLzBZCVihGBXAmJu/uUr8= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=