Complete WebAuthn backend + push notification mention parsing

Backend:
- internal/auth/webauthn.go: full RegisterBegin/Finish, LoginBegin/Finish implementation
  - In-memory challenge store with eviction
  - Credential storage in webauthn_credentials table
  - Session creation on successful passkey login
  - Cookie-based challenge linking for login flow
- internal/message/mentions.go: @mention parsing and push notification dispatch
  - Parses @user, @everyone, @role mentions
  - Skips DND users
  - Async goroutine dispatch for non-blocking
- internal/message/handlers.go: wired mention handler into Create
- cmd/server/main.go: added push handler initialization, pass to message handler
This commit is contained in:
2026-06-28 19:06:03 -04:00
parent 0f99ee53bc
commit 613d4b8c6e
4 changed files with 576 additions and 227 deletions
+131 -184
View File
@@ -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
}