Files
dumpsterChat/internal/message/handlers.go
T
hobokenchicken ca7a9e30f1 fix: message list now returns most recent 50, not oldest 50
Query was ORDER BY ASC LIMIT 50 — returning the 50 oldest messages.
On page refresh users saw ancient messages instead of recent ones.
Changed to ORDER BY DESC LIMIT then reverse in Go so the API still
returns chronological order but from the bottom of the history.
2026-07-02 09:19:31 -04:00

676 lines
20 KiB
Go

package message
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/embed"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
"github.com/go-chi/chi/v5"
)
type Handler struct {
db *sql.DB
hub *gateway.Hub
mentions *MentionHandler
pushHandler *push.Handler
logger *slog.Logger
checker *permissions.Checker
}
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler {
return &Handler{
db: db,
hub: hub,
mentions: NewMentionHandler(db, pushHandler, logger),
pushHandler: pushHandler,
logger: logger,
checker: checker,
}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Get("/search", h.Search)
r.Post("/bulk-delete", h.BulkDelete)
r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete)
}
type bulkDeleteRequest struct {
Messages []string `json:"messages"`
}
type bulkDeleteResponse struct {
Deleted int `json:"deleted"`
}
// BulkDelete deletes up to 100 messages in a channel. Requires MANAGE_MESSAGES
// and messages must be within the last 14 days.
func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
userID, serverID, ok := h.requireChannelAccess(w, r, channelID, 0)
if !ok {
return
}
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_MESSAGES)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !allowed {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
var req bulkDeleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if len(req.Messages) == 0 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: 0})
return
}
if len(req.Messages) > 100 {
http.Error(w, `{"error":"too many messages (max 100)"}`, http.StatusBadRequest)
return
}
// ponytail: simple IN query with 14-day guard and channel_id filter in DB.
placeholders := make([]string, len(req.Messages))
args := make([]interface{}, 0, len(req.Messages)+1)
for i, id := range req.Messages {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args = append(args, id)
}
args = append(args, channelID)
query := fmt.Sprintf(`
DELETE FROM messages
WHERE id IN (%s) AND channel_id = $%d AND created_at > NOW() - INTERVAL '14 days'
`, strings.Join(placeholders, ","), len(args))
res, err := h.db.ExecContext(r.Context(), query, args...)
if err != nil {
http.Error(w, `{"error":"failed to delete messages"}`, http.StatusInternalServerError)
return
}
deleted, _ := res.RowsAffected()
if h.hub != nil {
for _, id := range req.Messages {
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageDelete,
Data: map[string]string{
"id": id,
"channel_id": channelID,
},
})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: int(deleted)})
}
type embedResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
SiteName string `json:"site_name"`
}
type messageResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
AuthorID string `json:"author_id"`
AuthorName string `json:"author_username"`
DisplayName *string `json:"author_display_name"`
Content string `json:"content"`
ReplyTo *string `json:"reply_to,omitempty"`
EditedAt *string `json:"edited_at"`
CreatedAt string `json:"created_at"`
Embeds []embedResponse `json:"embeds"`
}
// isMember checks whether the given user is a member of the given server.
func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, error) {
var exists bool
err := h.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
`, userID, serverID).Scan(&exists)
return exists, err
}
type createMessageRequest struct {
Content string `json:"content"`
ReplyTo *string `json:"reply_to,omitempty"`
}
// @Summary Create a message
// @Description Send a message to a channel
// @Tags messages
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param channelID path string true "Channel ID"
// @Param body body createMessageRequest true "Message data"
// @Success 201 {object} messageResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /channels/{channelID}/messages [post]
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
userID, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
if !ok {
return
}
var req createMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Content == "" {
http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest)
return
}
if len(req.Content) > 4000 {
http.Error(w, `{"error":"message too long (max 4000 chars)"}`, http.StatusBadRequest)
return
}
// Check server mute.
muted, err := moderation.IsMuted(r.Context(), h.db, serverID, userID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if muted {
http.Error(w, `{"error":"you are muted in this server"}`, http.StatusForbidden)
return
}
// Check slowmode.
if retryAfter, active := h.checkSlowmode(r.Context(), channelID, userID); active {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "slowmode",
"retry_after": retryAfter,
"retry_after_ms": retryAfter * 1000,
})
return
}
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, reply_to)
VALUES ($1, $2, $3, $4)
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, created_at::text
`, channelID, userID, req.Content, replyTo).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &createdAt,
)
if err != nil {
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
return
}
// Fetch author info
err = h.db.QueryRowContext(r.Context(), `
SELECT username, display_name FROM users WHERE id = $1
`, userID).Scan(&msg.AuthorName, &msg.DisplayName)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Broadcast MESSAGE_CREATE event via WebSocket (scoped to server)
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageCreate,
Data: msg,
})
// Dispatch push notifications for @mentions
go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content)
// Dispatch push notifications to channel members
go func() {
var channelName string
_ = h.db.QueryRowContext(context.Background(), `SELECT name FROM channels WHERE id = $1`, channelID).Scan(&channelName)
if channelName == "" {
channelName = channelID
}
if h.pushHandler != nil {
h.pushHandler.SendChannelNotification(context.Background(), channelID, userID, channelName, req.Content)
}
}()
// Fetch and store link embeds asynchronously.
go embed.FetchAndStore(h.db, msg.ID, req.Content)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(msg)
}
func (h *Handler) checkSlowmode(ctx context.Context, channelID, userID string) (int, bool) {
var slowmode int
err := h.db.QueryRowContext(ctx, `SELECT slowmode_seconds FROM channels WHERE id = $1`, channelID).Scan(&slowmode)
if err != nil || slowmode <= 0 {
return 0, false
}
var elapsed int
err = h.db.QueryRowContext(ctx, `
SELECT COALESCE(EXTRACT(EPOCH FROM (NOW() - created_at))::int, 0)
FROM messages
WHERE channel_id = $1 AND author_id = $2
ORDER BY created_at DESC
LIMIT 1
`, channelID, userID).Scan(&elapsed)
if err != nil {
return 0, false
}
if elapsed < slowmode {
return slowmode - elapsed, true
}
return 0, false
}
type updateMessageRequest struct {
Content string `json:"content"`
}
// @Summary Update a message
// @Description Edit a message (author only)
// @Tags messages
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param channelID path string true "Channel ID"
// @Param messageID path string true "Message ID"
// @Param body body updateMessageRequest true "Message update data"
// @Success 200 {object} messageResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /channels/{channelID}/messages/{messageID} [patch]
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
}
var req updateMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Content == "" {
http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest)
return
}
if len(req.Content) > 4000 {
http.Error(w, `{"error":"message too long (max 4000 chars)"}`, http.StatusBadRequest)
return
}
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
var replyTo sql.NullString
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, reply_to::text, edited_at::text, created_at::text
`, req.Content, messageID, userID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &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
}
err = h.db.QueryRowContext(r.Context(), `
SELECT username, display_name FROM users WHERE id = $1
`, userID).Scan(&msg.AuthorName, &msg.DisplayName)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Re-fetch embeds after edit.
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
// Look up serverID for scoped broadcast
serverID, _ := h.hub.ServerIDForChannel(r.Context(), msg.ChannelID)
if serverID != "" {
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageUpdate,
Data: msg,
})
}
go embed.FetchAndStore(h.db, msg.ID, req.Content)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(msg)
}
// @Summary Delete a message
// @Description Delete a message (author only)
// @Tags messages
// @Security SessionAuth
// @Param channelID path string true "Channel ID"
// @Param messageID path string true "Message ID"
// @Success 204
// @Failure 401 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /channels/{channelID}/messages/{messageID} [delete]
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
}
var channelID string
err := h.db.QueryRowContext(r.Context(), `
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 or not authorized"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError)
return
}
// Look up serverID for scoped broadcast
serverID, _ := h.hub.ServerIDForChannel(r.Context(), channelID)
if serverID != "" {
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageDelete,
Data: map[string]string{
"id": messageID,
"channel_id": channelID,
},
})
}
w.WriteHeader(http.StatusNoContent)
}
// @Summary List messages
// @Description List messages in a channel with pagination
// @Tags messages
// @Produce json
// @Security SessionAuth
// @Param channelID path string true "Channel ID"
// @Param limit query int false "Max messages to return (1-100, default 50)"
// @Param before query string false "Message ID to fetch messages before"
// @Success 200 {array} messageResponse
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /channels/{channelID}/messages [get]
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
userID, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
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.reply_to::text, 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.reply_to::text, 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
var replyTo sql.NullString
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt)
if err != nil {
continue
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
messages = append(messages, msg)
}
messages = h.attachEmbeds(r.Context(), messages)
// Reverse: query returns DESC (newest first), client expects ASC (oldest first).
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
_ = userID
}
// attachEmbeds loads embeds for the given messages and attaches them.
func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse) []messageResponse {
if len(messages) == 0 {
return messages
}
ids := make([]string, len(messages))
for i, m := range messages {
ids[i] = m.ID
}
embedMap, err := embed.LoadForMessages(ctx, h.db, ids)
if err != nil {
return messages
}
for i := range messages {
if embs, ok := embedMap[messages[i].ID]; ok {
out := make([]embedResponse, len(embs))
for j, e := range embs {
out[j] = embedResponse{
ID: e.ID,
URL: e.URL,
Title: e.Title,
Description: e.Description,
ImageURL: e.ImageURL,
SiteName: e.SiteName,
}
}
messages[i].Embeds = out
}
}
return messages
}
// Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
_, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
if !ok {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
http.Error(w, `{"error":"missing q parameter"}`, http.StatusBadRequest)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 25
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
rows, err := h.db.QueryContext(r.Context(), `
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.created_at::text,
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
FROM messages m
JOIN users u ON m.author_id = u.id
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
ORDER BY rank DESC, m.created_at DESC
LIMIT $3
`, channelID, q, limit)
if err != nil {
http.Error(w, `{"error":"failed to search 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
var replyTo sql.NullString
var rank float64
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &createdAt, &rank)
if err != nil {
continue
}
if replyTo.Valid {
msg.ReplyTo = &replyTo.String
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
messages = append(messages, msg)
}
messages = h.attachEmbeds(r.Context(), messages)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
// Returns (userID, serverID, ok). If required is non-zero, also checks the permission against
// channel-level overrides.
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string, required int64) (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
}
// Check channel-specific permission if a bitmap was provided.
if required != 0 {
allowed, checkErr := h.checker.CheckChannelPermission(r.Context(), serverID, userID, channelID, required)
if checkErr != nil {
h.logger.Error("permission check failed", "error", checkErr)
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return "", "", false
}
if !allowed {
http.Error(w, `{"error":"missing permissions"}`, http.StatusForbidden)
return "", "", false
}
}
return userID, serverID, true
}