diff --git a/cmd/server/main.go b/cmd/server/main.go index 5832826..71d3b92 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,6 +18,7 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/invite" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/reaction" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload" @@ -76,6 +77,12 @@ func main() { logger.Warn("voice client not configured (missing LIVEKIT_API_KEY/SECRET)") } + // Push notification handler (nil if no VAPID keys) + pushHandler := push.NewHandler(database.DB, cfg.WebPush.PublicKey, cfg.WebPush.PrivateKey, cfg.WebPush.Subject, logger) + if cfg.WebPush.PublicKey == "" { + logger.Warn("push notifications not configured (missing VAPID_PUBLIC_KEY)") + } + // Auth handler authHandler := auth.NewHandler(database.DB, cfg) @@ -122,7 +129,7 @@ func main() { // Messages (under channels) r.Route("/channels/{channelID}/messages", func(r chi.Router) { - message.NewHandler(database.DB, hub).RegisterRoutes(r) + message.NewHandler(database.DB, hub, pushHandler, logger).RegisterRoutes(r) }) // Giphy search diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go index 5b7452a..7f3ae83 100644 --- a/internal/auth/webauthn.go +++ b/internal/auth/webauthn.go @@ -1,38 +1,83 @@ package auth import ( + "context" + "crypto/rand" "database/sql" + "encoding/base64" "encoding/json" "log/slog" "net/http" + "sync" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" ) +// challengeStore temporarily stores WebAuthn session data between begin and finish. +type challengeStore struct { + mu sync.Mutex + data map[string]*webauthn.SessionData + order []string +} + +func newChallengeStore() *challengeStore { + return &challengeStore{data: make(map[string]*webauthn.SessionData)} +} + +func (s *challengeStore) Set(key string, sd *webauthn.SessionData) { + s.mu.Lock() + defer s.mu.Unlock() + s.data[key] = sd + s.order = append(s.order, key) + // Evict old entries (keep max 100) + for len(s.order) > 100 { + delete(s.data, s.order[0]) + s.order = s.order[1:] + } +} + +func (s *challengeStore) Get(key string) (*webauthn.SessionData, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sd, ok := s.data[key] + if ok { + delete(s.data, key) + } + return sd, ok +} + type WebAuthnHandler struct { - db *sql.DB - wa *webauthn.WebAuthn - sessions *SessionStore - logger *slog.Logger + db *sql.DB + wa *webauthn.WebAuthn + sessions *SessionStore + challenges *challengeStore + logger *slog.Logger } func NewWebAuthnHandler(db *sql.DB, cfg *config.Config, sessions *SessionStore, logger *slog.Logger) (*WebAuthnHandler, error) { + origins := []string{"https://" + cfg.Host, "http://" + cfg.Host + ":" + cfg.Port} + if cfg.Host == "localhost" { + origins = append(origins, "http://localhost:"+cfg.Port) + } + wa, err := webauthn.New(&webauthn.Config{ RPDisplayName: "Dumpster", RPID: cfg.Host, - RPOrigins: []string{"https://" + cfg.Host, "http://" + cfg.Host + ":" + cfg.Port}, + RPOrigins: origins, }) if err != nil { return nil, err } return &WebAuthnHandler{ - db: db, - wa: wa, - sessions: sessions, - logger: logger, + db: db, + wa: wa, + sessions: sessions, + challenges: newChallengeStore(), + logger: logger, }, nil } @@ -43,6 +88,7 @@ func (h *WebAuthnHandler) RegisterRoutes(r *http.ServeMux) { r.HandleFunc("POST /auth/webauthn/login/finish", h.LoginFinish) } +// RegisterBegin starts passkey registration for an authenticated user. func (h *WebAuthnHandler) RegisterBegin(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { @@ -50,42 +96,27 @@ func (h *WebAuthnHandler) RegisterBegin(w http.ResponseWriter, r *http.Request) return } - var user WebAuthnUser - err := h.db.QueryRowContext(r.Context(), - `SELECT id, username, display_name FROM users WHERE id = $1`, userID, - ).Scan(&user.ID, &user.Username, &user.DisplayName) + user, err := h.loadUser(r.Context(), userID) if err != nil { http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) return } - // Get existing credentials - rows, _ := h.db.QueryContext(r.Context(), - `SELECT credential_id, public_key FROM webauthn_credentials WHERE user_id = $1`, userID) - if rows != nil { - defer rows.Close() - for rows.Next() { - var credID, pubKey []byte - rows.Scan(&credID, &pubKey) - user.credentials = append(user.credentials, webauthn.Credential{ - ID: credID, - PublicKey: pubKey, - }) - } - } - - options, sessionData, err := h.wa.BeginRegistration(&user) + options, sessionData, err := h.wa.BeginRegistration(user) if err != nil { + h.logger.Error("webauthn register begin failed", "error", err) http.Error(w, `{"error":"registration failed"}`, http.StatusInternalServerError) return } - // Store session data (simplified: in production use a proper session store) + // Store challenge keyed by user ID + h.challenges.Set(userID, sessionData) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(options) - _ = sessionData // TODO: store in session for finish step } +// RegisterFinish completes passkey registration. func (h *WebAuthnHandler) RegisterFinish(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { @@ -93,19 +124,231 @@ func (h *WebAuthnHandler) RegisterFinish(w http.ResponseWriter, r *http.Request) return } - // TODO: implement full registration finish with session data - http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented) - _ = userID + sessionData, ok := h.challenges.Get(userID) + if !ok { + http.Error(w, `{"error":"no pending registration"}`, http.StatusBadRequest) + return + } + + user, err := h.loadUser(r.Context(), userID) + if err != nil { + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + + credential, err := h.wa.FinishRegistration(user, *sessionData, r) + if err != nil { + h.logger.Error("webauthn register finish failed", "error", err) + http.Error(w, `{"error":"registration verification failed"}`, http.StatusBadRequest) + return + } + + // Store credential in database + _, err = h.db.ExecContext(r.Context(), + `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, aaguid, sign_count) + VALUES ($1, $2, $3, $4, $5)`, + userID, + credential.ID, + credential.PublicKey, + credential.Authenticator.AAGUID, + credential.Authenticator.SignCount, + ) + if err != nil { + h.logger.Error("failed to store credential", "error", err) + http.Error(w, `{"error":"failed to store credential"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "registered"}) } +// LoginBegin starts passkey authentication (no auth required). func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) { - // TODO: implement login begin - http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented) + // Get all registered credentials for the login ceremony + rows, err := h.db.QueryContext(r.Context(), + `SELECT credential_id, user_id FROM webauthn_credentials LIMIT 1000`) + if err != nil { + h.logger.Error("failed to query credentials", "error", err) + http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + var descriptors []protocol.CredentialDescriptor + for rows.Next() { + var credID []byte + var userID string + if err := rows.Scan(&credID, &userID); err != nil { + continue + } + descriptors = append(descriptors, protocol.CredentialDescriptor{ + Type: protocol.PublicKeyCredentialType, + CredentialID: credID, + }) + } + + options, sessionData, err := h.wa.BeginLogin(nil, webauthn.WithAllowedCredentials(descriptors)) + if err != nil { + h.logger.Error("webauthn login begin failed", "error", err) + http.Error(w, `{"error":"login failed"}`, http.StatusInternalServerError) + return + } + + // Store challenge with a random key + challengeKey := randomChallenge() + h.challenges.Set(challengeKey, sessionData) + + // Set cookie to link challenge to user + http.SetCookie(w, &http.Cookie{ + Name: "webauthn_challenge", + Value: challengeKey, + Path: "/", + HttpOnly: true, + MaxAge: 300, // 5 minutes + SameSite: http.SameSiteStrictMode, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(options) } +// LoginFinish completes passkey authentication. func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) { - // TODO: implement login finish - http.Error(w, `{"error":"not implemented"}`, http.StatusNotImplemented) + // Get challenge key from cookie + cookie, err := r.Cookie("webauthn_challenge") + if err != nil { + http.Error(w, `{"error":"no pending login"}`, http.StatusBadRequest) + return + } + + sessionData, ok := h.challenges.Get(cookie.Value) + if !ok { + http.Error(w, `{"error":"challenge expired"}`, http.StatusBadRequest) + return + } + + // Parse the credential response + var parsedResponse struct { + ID string `json:"id"` + RawID string `json:"rawId"` + Type string `json:"type"` + Response struct { + AuthenticatorData string `json:"authenticatorData"` + ClientDataJSON string `json:"clientDataJSON"` + Signature string `json:"signature"` + UserHandle string `json:"userHandle"` + } `json:"response"` + } + if err := json.NewDecoder(r.Body).Decode(&parsedResponse); err != nil { + http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) + return + } + + // Find the credential in the database + var credentialID []byte + credentialID, err = base64.StdEncoding.DecodeString(parsedResponse.ID) + if err != nil { + // Try raw URL encoding + credentialID, err = base64.RawURLEncoding.DecodeString(parsedResponse.ID) + if err != nil { + http.Error(w, `{"error":"invalid credential"}`, http.StatusBadRequest) + return + } + } + + var storedUserID string + var storedPubKey []byte + var signCount int64 + err = h.db.QueryRowContext(r.Context(), + `SELECT user_id, public_key, sign_count FROM webauthn_credentials WHERE credential_id = $1`, + credentialID, + ).Scan(&storedUserID, &storedPubKey, &signCount) + if err != nil { + http.Error(w, `{"error":"credential not found"}`, http.StatusUnauthorized) + return + } + + user, err := h.loadUser(r.Context(), storedUserID) + if err != nil { + http.Error(w, `{"error":"user not found"}`, http.StatusNotFound) + return + } + + _, err = h.wa.FinishLogin(user, *sessionData, r) + if err != nil { + h.logger.Error("webauthn login finish failed", "error", err) + http.Error(w, `{"error":"login verification failed"}`, http.StatusUnauthorized) + return + } + + // Update sign count + h.db.ExecContext(r.Context(), + `UPDATE webauthn_credentials SET sign_count = sign_count + 1 WHERE credential_id = $1`, + credentialID) + + // Create session + token, err := h.sessions.Create(r.Context(), storedUserID) + if err != nil { + h.logger.Error("failed to create session", "error", err) + http.Error(w, `{"error":"session creation failed"}`, http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "dumpster_session", + Value: token, + Path: "/", + HttpOnly: true, + MaxAge: 86400 * 30, // 30 days + SameSite: http.SameSiteStrictMode, + }) + + // Clear challenge cookie + http.SetCookie(w, &http.Cookie{ + Name: "webauthn_challenge", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + SameSite: http.SameSiteStrictMode, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"}) +} + +// loadUser loads a user and their credentials from the database. +func (h *WebAuthnHandler) loadUser(ctx context.Context, userID string) (*WebAuthnUser, error) { + var user WebAuthnUser + err := h.db.QueryRowContext(ctx, + `SELECT id, username, display_name FROM users WHERE id = $1`, userID, + ).Scan(&user.ID, &user.Username, &user.DisplayName) + if err != nil { + return nil, err + } + + rows, err := h.db.QueryContext(ctx, + `SELECT credential_id, public_key, sign_count FROM webauthn_credentials WHERE user_id = $1`, userID) + if err != nil { + return &user, nil + } + defer rows.Close() + + for rows.Next() { + var credID, pubKey []byte + var signCount uint32 + rows.Scan(&credID, &pubKey, &signCount) + user.credentials = append(user.credentials, webauthn.Credential{ + ID: credID, + PublicKey: pubKey, + Authenticator: webauthn.Authenticator{ + SignCount: signCount, + }, + }) + } + + return &user, nil } // WebAuthnUser implements the webauthn.User interface @@ -116,8 +359,14 @@ type WebAuthnUser struct { credentials []webauthn.Credential } -func (u *WebAuthnUser) WebAuthnID() []byte { return []byte(u.ID) } -func (u *WebAuthnUser) WebAuthnName() string { return u.Username } -func (u *WebAuthnUser) WebAuthnDisplayName() string { return u.DisplayName } -func (u *WebAuthnUser) WebAuthnIcon() string { return "" } +func (u *WebAuthnUser) WebAuthnID() []byte { return []byte(u.ID) } +func (u *WebAuthnUser) WebAuthnName() string { return u.Username } +func (u *WebAuthnUser) WebAuthnDisplayName() string { return u.DisplayName } +func (u *WebAuthnUser) WebAuthnIcon() string { return "" } func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +func randomChallenge() string { + b := make([]byte, 32) + rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 1a08c59..39e0529 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -5,21 +5,28 @@ import ( "database/sql" "encoding/json" "errors" + "log/slog" "net/http" "strconv" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" "github.com/go-chi/chi/v5" ) type Handler struct { - db *sql.DB - hub *gateway.Hub + db *sql.DB + hub *gateway.Hub + mentions *MentionHandler } -func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { - return &Handler{db: db, hub: hub} +func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler { + return &Handler{ + db: db, + hub: hub, + mentions: NewMentionHandler(db, pushHandler, logger), + } } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -49,134 +56,9 @@ func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, return exists, err } -// serverIDForChannel returns the server_id that owns the given channel. -func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) { - var serverID string - err := h.db.QueryRowContext(ctx, ` - SELECT server_id FROM channels WHERE id = $1 - `, channelID).Scan(&serverID) - return serverID, err -} - -// requireChannelAccess verifies the user is a member of the server owning the channel, -// returning the server_id if successful. -func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) { - userID, ok := middleware.UserIDFromContext(r.Context()) - if !ok { - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return "", false - } - - serverID, err := h.serverIDForChannel(r.Context(), channelID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) - } else { - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - } - return "", false - } - - member, err := h.isMember(r.Context(), userID, serverID) - if err != nil { - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - return "", false - } - if !member { - http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) - return "", false - } - - return userID, true -} - -func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - channelID := chi.URLParam(r, "channelID") - if _, ok := h.requireChannelAccess(w, r, channelID); !ok { - return - } - - // Parse query params - limit := 50 - if l := r.URL.Query().Get("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { - limit = parsed - } - } - before := r.URL.Query().Get("before") - - var rows *sql.Rows - var err error - - if before != "" { - // Fetch the created_at of the cursor message - var cursorTime sql.NullString - err = h.db.QueryRowContext(r.Context(), ` - SELECT created_at::text FROM messages WHERE id = $1 - `, before).Scan(&cursorTime) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - http.Error(w, `{"error":"cursor message not found"}`, http.StatusBadRequest) - return - } - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - return - } - - rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, - m.content, m.edited_at::text, m.created_at::text - FROM messages m - INNER JOIN users u ON u.id = m.author_id - WHERE m.channel_id = $1 AND m.created_at < $2::timestamptz - ORDER BY m.created_at DESC - LIMIT $3 - `, channelID, cursorTime.String, limit) - } else { - rows, err = h.db.QueryContext(r.Context(), ` - SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, - m.content, m.edited_at::text, m.created_at::text - FROM messages m - INNER JOIN users u ON u.id = m.author_id - WHERE m.channel_id = $1 - ORDER BY m.created_at DESC - LIMIT $2 - `, channelID, limit) - } - if err != nil { - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - return - } - defer rows.Close() - - messages := make([]messageResponse, 0) - for rows.Next() { - var msg messageResponse - var editedAt, createdAt sql.NullString - if err := rows.Scan( - &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, - &msg.Content, &editedAt, &createdAt, - ); err != nil { - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - return - } - if editedAt.Valid { - msg.EditedAt = &editedAt.String - } - msg.CreatedAt = createdAt.String - messages = append(messages, msg) - } - if err := rows.Err(); err != nil { - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(messages) -} - type createMessageRequest struct { - Content string `json:"content"` + Content string `json:"content"` + ReplyTo *string `json:"reply_to,omitempty"` } func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { @@ -199,11 +81,15 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString + var replyTo sql.NullString + if req.ReplyTo != nil { + replyTo = sql.NullString{String: *req.ReplyTo, Valid: true} + } err := h.db.QueryRowContext(r.Context(), ` - INSERT INTO messages (channel_id, author_id, content) - VALUES ($1, $2, $3) + INSERT INTO messages (channel_id, author_id, content, reply_to) + VALUES ($1, $2, $3, $4) RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text - `, channelID, userID, req.Content).Scan( + `, channelID, userID, req.Content, replyTo).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt, ) if err != nil { @@ -231,6 +117,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { Data: msg, }) + // Dispatch push notifications for @mentions + go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(msg) @@ -241,14 +130,13 @@ type updateMessageRequest struct { } func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + messageID := chi.URLParam(r, "messageID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } - messageID := chi.URLParam(r, "messageID") - var req updateMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) @@ -259,44 +147,28 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } - // Verify authorship - var authorID string - err := h.db.QueryRowContext(r.Context(), ` - SELECT author_id FROM messages WHERE id = $1 - `, messageID).Scan(&authorID) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) - return - } - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - return - } - if authorID != userID { - http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) - return - } - var msg messageResponse var editedAt sql.NullString var createdAt sql.NullString - err = h.db.QueryRowContext(r.Context(), ` - UPDATE messages - SET content = $1, edited_at = NOW() - WHERE id = $2 + err := h.db.QueryRowContext(r.Context(), ` + UPDATE messages SET content = $1, edited_at = NOW() + WHERE id = $2 AND author_id = $3 RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text - `, req.Content, messageID).Scan( + `, req.Content, messageID, userID).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt, ) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound) + return + } http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError) return } - // Fetch author info err = h.db.QueryRowContext(r.Context(), ` SELECT username, display_name FROM users WHERE id = $1 - `, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName) + `, userID).Scan(&msg.AuthorName, &msg.DisplayName) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return @@ -307,7 +179,6 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { } msg.CreatedAt = createdAt.String - // Broadcast MESSAGE_UPDATE event via WebSocket h.hub.BroadcastEvent(gateway.Event{ Type: gateway.EventMessageUpdate, Data: msg, @@ -318,48 +189,124 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { } func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + messageID := chi.URLParam(r, "messageID") userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } - messageID := chi.URLParam(r, "messageID") - - // Verify authorship and get channel_id for the broadcast - var authorID, channelID string + var channelID string err := h.db.QueryRowContext(r.Context(), ` - SELECT author_id, channel_id FROM messages WHERE id = $1 - `, messageID).Scan(&authorID, &channelID) + DELETE FROM messages WHERE id = $1 AND author_id = $2 + RETURNING channel_id + `, messageID, userID).Scan(&channelID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - http.Error(w, `{"error":"message not found"}`, http.StatusNotFound) + http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound) return } - http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) - return - } - if authorID != userID { - http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) - return - } - - _, err = h.db.ExecContext(r.Context(), ` - DELETE FROM messages WHERE id = $1 - `, messageID) - if err != nil { http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError) return } - // Broadcast MESSAGE_DELETE event via WebSocket h.hub.BroadcastEvent(gateway.Event{ Type: gateway.EventMessageDelete, Data: map[string]string{ - "id": messageID, - "channel_id": channelID, + "id": messageID, + "channel_id": channelID, }, }) w.WriteHeader(http.StatusNoContent) } + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + channelID := chi.URLParam(r, "channelID") + userID, ok := h.requireChannelAccess(w, r, channelID) + if !ok { + return + } + + limitStr := r.URL.Query().Get("limit") + limit := 50 + if limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + before := r.URL.Query().Get("before") + + var rows *sql.Rows + var err error + if before != "" { + rows, err = h.db.QueryContext(r.Context(), ` + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text + FROM messages m + JOIN users u ON m.author_id = u.id + WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2) + ORDER BY m.created_at DESC + LIMIT $3 + `, channelID, before, limit) + } else { + rows, err = h.db.QueryContext(r.Context(), ` + SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text + FROM messages m + JOIN users u ON m.author_id = u.id + WHERE m.channel_id = $1 + ORDER BY m.created_at DESC + LIMIT $2 + `, channelID, limit) + } + if err != nil { + http.Error(w, `{"error":"failed to list messages"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + messages := make([]messageResponse, 0) + for rows.Next() { + var msg messageResponse + var editedAt sql.NullString + var createdAt sql.NullString + err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &editedAt, &createdAt) + if err != nil { + continue + } + if editedAt.Valid { + msg.EditedAt = &editedAt.String + } + msg.CreatedAt = createdAt.String + messages = append(messages, msg) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) + + _ = userID +} + +// requireChannelAccess checks if user is authenticated and is a member of the channel's server. +func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return "", false + } + + var serverID string + err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID) + if err != nil { + http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) + return "", false + } + + isMember, err := h.isMember(r.Context(), userID, serverID) + if err != nil || !isMember { + http.Error(w, `{"error":"not a member"}`, http.StatusForbidden) + return "", false + } + + return userID, true +} diff --git a/internal/message/mentions.go b/internal/message/mentions.go new file mode 100644 index 0000000..b43508b --- /dev/null +++ b/internal/message/mentions.go @@ -0,0 +1,146 @@ +package message + +import ( + "context" + "database/sql" + "log/slog" + "regexp" + "strings" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push" +) + +var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`) +var everyoneMention = "@everyone" +var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`) + +// MentionHandler dispatches push notifications for @mentions. +type MentionHandler struct { + db *sql.DB + push *push.Handler + logger *slog.Logger +} + +func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logger) *MentionHandler { + return &MentionHandler{ + db: db, + push: pushHandler, + logger: logger, + } +} + +// ParseAndNotify parses message content for mentions and sends push notifications. +func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) { + // Find individual user mentions + userMatches := mentionRegex.FindAllStringSubmatch(content, -1) + mentionedUsers := make(map[string]bool) + for _, match := range userMatches { + if len(match) > 1 { + mentionedUsers[match[1]] = true + } + } + + // Check for @everyone + isEveryone := strings.Contains(content, everyoneMention) + + // Get channel info for notification + var serverID, channelName string + err := m.db.QueryRowContext(ctx, + `SELECT server_id, name FROM channels WHERE id = $1`, channelID, + ).Scan(&serverID, &channelName) + if err != nil { + m.logger.Error("failed to get channel info for mentions", "error", err) + return + } + + // Get author info + var authorName string + err = m.db.QueryRowContext(ctx, + `SELECT COALESCE(display_name, username) FROM users WHERE id = $1`, authorID, + ).Scan(&authorName) + if err != nil { + m.logger.Error("failed to get author info for mentions", "error", err) + return + } + + // Truncate content for notification + notifContent := content + if len(notifContent) > 200 { + notifContent = notifContent[:200] + "..." + } + + payload := map[string]interface{}{ + "title": authorName + " in #" + channelName, + "body": notifContent, + "url": "/channels/" + channelID, + } + + if isEveryone { + // Send to all server members + rows, err := m.db.QueryContext(ctx, + `SELECT user_id FROM members WHERE server_id = $1 AND user_id != $2`, + serverID, authorID, + ) + if err != nil { + m.logger.Error("failed to query server members for @everyone", "error", err) + return + } + defer rows.Close() + + for rows.Next() { + var userID string + if err := rows.Scan(&userID); err != nil { + continue + } + go m.push.SendPush(ctx, userID, payload) + } + return + } + + // Check for role mentions + roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1) + if len(roleMatches) > 0 { + for _, match := range roleMatches { + if len(match) > 1 { + roleID := match[1] + // Get users with this role + rows, err := m.db.QueryContext(ctx, + `SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`, + roleID, authorID, + ) + if err != nil { + m.logger.Error("failed to query role members", "error", err, "role_id", roleID) + continue + } + for rows.Next() { + var userID string + if err := rows.Scan(&userID); err != nil { + continue + } + mentionedUsers[userID] = true + } + rows.Close() + } + } + } + + // Remove the author from mentions + delete(mentionedUsers, authorID) + + // Send push to individually mentioned users + for userID := range mentionedUsers { + // Check if user is in DND status + var status string + err := m.db.QueryRowContext(ctx, + `SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID, + ).Scan(&status) + if err != nil { + continue + } + if status == "dnd" { + continue + } + + go m.push.SendPush(ctx, userID, payload) + } +}