40f8d193ff
- /poll command opens creation modal (2-10 options) - PollDisplay with vote bars, percentages, live WS updates - Backend: polls/poll_options/poll_votes tables, Create/Get/Vote endpoints - attachPolls enriches message list responses - POLL_UPDATE broadcast on vote for real-time sync
1100 lines
32 KiB
Go
1100 lines
32 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)
|
|
r.Get("/pinned", h.ListPinned)
|
|
r.Put("/{messageID}/pin", h.Pin)
|
|
r.Delete("/{messageID}/pin", h.Unpin)
|
|
}
|
|
|
|
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 reactionResponse struct {
|
|
ID string `json:"id"`
|
|
MessageID string `json:"message_id"`
|
|
UserID string `json:"user_id"`
|
|
Emoji string `json:"emoji"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type emojiGroup struct {
|
|
Emoji string `json:"emoji"`
|
|
Count int `json:"count"`
|
|
Users []string `json:"users"`
|
|
Details []reactionResponse `json:"details"`
|
|
}
|
|
|
|
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"`
|
|
Pinned bool `json:"pinned"`
|
|
CreatedAt string `json:"created_at"`
|
|
Embeds []embedResponse `json:"embeds"`
|
|
Reactions []emojiGroup `json:"reactions"`
|
|
Poll *pollResponse `json:"poll,omitempty"`
|
|
}
|
|
|
|
// 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, pinned, created_at::text
|
|
`, channelID, userID, req.Content, replyTo).Scan(
|
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &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
|
|
msg.Reactions = make([]emojiGroup, 0)
|
|
|
|
// 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, pinned, created_at::text
|
|
`, req.Content, messageID, userID).Scan(
|
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &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
|
|
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
|
|
|
// 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.pinned, 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.pinned, 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, &msg.Pinned, &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)
|
|
messages = h.attachReactions(r.Context(), messages)
|
|
messages = h.attachPolls(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
|
|
}
|
|
|
|
func (h *Handler) attachReactions(ctx context.Context, messages []messageResponse) []messageResponse {
|
|
if len(messages) == 0 {
|
|
return messages
|
|
}
|
|
|
|
msgIDs := make([]string, len(messages))
|
|
msgMap := make(map[string]int)
|
|
for i, msg := range messages {
|
|
msgIDs[i] = msg.ID
|
|
msgMap[msg.ID] = i
|
|
messages[i].Reactions = make([]emojiGroup, 0)
|
|
}
|
|
|
|
placeholders := make([]string, len(msgIDs))
|
|
args := make([]interface{}, len(msgIDs))
|
|
for i, id := range msgIDs {
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
args[i] = id
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT id, message_id, user_id, emoji, created_at::text
|
|
FROM reactions
|
|
WHERE message_id IN (%s)
|
|
ORDER BY created_at ASC
|
|
`, strings.Join(placeholders, ", "))
|
|
|
|
rows, err := h.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return messages
|
|
}
|
|
defer rows.Close()
|
|
|
|
reactionsMap := make(map[string]map[string]*emojiGroup)
|
|
emojiOrderMap := make(map[string][]string)
|
|
|
|
for rows.Next() {
|
|
var reaction reactionResponse
|
|
var createdAt sql.NullString
|
|
if err := rows.Scan(&reaction.ID, &reaction.MessageID, &reaction.UserID, &reaction.Emoji, &createdAt); err != nil {
|
|
continue
|
|
}
|
|
reaction.CreatedAt = createdAt.String
|
|
|
|
msgID := reaction.MessageID
|
|
if _, ok := reactionsMap[msgID]; !ok {
|
|
reactionsMap[msgID] = make(map[string]*emojiGroup)
|
|
emojiOrderMap[msgID] = make([]string, 0)
|
|
}
|
|
|
|
group, ok := reactionsMap[msgID][reaction.Emoji]
|
|
if !ok {
|
|
group = &emojiGroup{
|
|
Emoji: reaction.Emoji,
|
|
Users: []string{},
|
|
Details: []reactionResponse{},
|
|
}
|
|
reactionsMap[msgID][reaction.Emoji] = group
|
|
emojiOrderMap[msgID] = append(emojiOrderMap[msgID], reaction.Emoji)
|
|
}
|
|
group.Count++
|
|
group.Users = append(group.Users, reaction.UserID)
|
|
group.Details = append(group.Details, reaction)
|
|
}
|
|
|
|
for msgID, emojisMap := range reactionsMap {
|
|
idx, ok := msgMap[msgID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
order := emojiOrderMap[msgID]
|
|
for _, emoji := range order {
|
|
messages[idx].Reactions = append(messages[idx].Reactions, *emojisMap[emoji])
|
|
}
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
// attachPolls loads poll data for messages that have polls.
|
|
func (h *Handler) attachPolls(ctx context.Context, messages []messageResponse) []messageResponse {
|
|
if len(messages) == 0 {
|
|
return messages
|
|
}
|
|
|
|
msgIDs := make([]string, len(messages))
|
|
for i, m := range messages {
|
|
msgIDs[i] = m.ID
|
|
}
|
|
|
|
placeholders := make([]string, len(msgIDs))
|
|
args := make([]interface{}, len(msgIDs))
|
|
for i, id := range msgIDs {
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
args[i] = id
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT p.id, p.message_id, p.question, p.created_at::text
|
|
FROM polls p
|
|
WHERE p.message_id IN (%s)
|
|
`, strings.Join(placeholders, ", "))
|
|
|
|
rows, err := h.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return messages
|
|
}
|
|
defer rows.Close()
|
|
|
|
msgMap := make(map[string]int)
|
|
for i, m := range messages {
|
|
msgMap[m.ID] = i
|
|
}
|
|
|
|
type pollRow struct {
|
|
ID string
|
|
MessageID string
|
|
Question string
|
|
CreatedAt string
|
|
}
|
|
pollRows := make([]pollRow, 0)
|
|
for rows.Next() {
|
|
var p pollRow
|
|
if err := rows.Scan(&p.ID, &p.MessageID, &p.Question, &p.CreatedAt); err != nil {
|
|
continue
|
|
}
|
|
pollRows = append(pollRows, p)
|
|
}
|
|
|
|
for _, pr := range pollRows {
|
|
idx, ok := msgMap[pr.MessageID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
poll := &pollResponse{
|
|
ID: pr.ID,
|
|
MessageID: pr.MessageID,
|
|
Question: pr.Question,
|
|
CreatedAt: pr.CreatedAt,
|
|
Options: make([]pollOptionResponse, 0),
|
|
}
|
|
|
|
optRows, err := h.db.QueryContext(ctx, `
|
|
SELECT po.id, po.text, po.position, COUNT(pv.id) as votes
|
|
FROM poll_options po
|
|
LEFT JOIN poll_votes pv ON pv.option_id = po.id
|
|
WHERE po.poll_id = $1
|
|
GROUP BY po.id, po.text, po.position
|
|
ORDER BY po.position
|
|
`, pr.ID)
|
|
if err == nil {
|
|
for optRows.Next() {
|
|
var opt pollOptionResponse
|
|
if err := optRows.Scan(&opt.ID, &opt.Text, &opt.Position, &opt.Votes); err != nil {
|
|
continue
|
|
}
|
|
opt.Voters = []string{}
|
|
poll.Options = append(poll.Options, opt)
|
|
}
|
|
optRows.Close()
|
|
|
|
for i, opt := range poll.Options {
|
|
voterRows, err := h.db.QueryContext(ctx, `SELECT user_id FROM poll_votes WHERE option_id = $1`, opt.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for voterRows.Next() {
|
|
var voterID string
|
|
if voterRows.Scan(&voterID) == nil {
|
|
poll.Options[i].Voters = append(poll.Options[i].Voters, voterID)
|
|
}
|
|
}
|
|
voterRows.Close()
|
|
}
|
|
}
|
|
|
|
messages[idx].Poll = poll
|
|
}
|
|
|
|
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.pinned, 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, &msg.Pinned, &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)
|
|
messages = h.attachReactions(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
|
|
}
|
|
|
|
func (h *Handler) Pin(w http.ResponseWriter, r *http.Request) {
|
|
channelID := chi.URLParam(r, "channelID")
|
|
messageID := chi.URLParam(r, "messageID")
|
|
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Verify the message exists and belongs to this channel
|
|
var msgChannelID string
|
|
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
|
|
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 msgChannelID != channelID {
|
|
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Check if already pinned
|
|
var isPinned bool
|
|
err = h.db.QueryRowContext(r.Context(), "SELECT pinned FROM messages WHERE id = $1", messageID).Scan(&isPinned)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if isPinned {
|
|
http.Error(w, `{"error":"message is already pinned"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Count pinned messages in this channel
|
|
var count int
|
|
err = h.db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND pinned = TRUE", channelID).Scan(&count)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if count >= 5 {
|
|
http.Error(w, `{"error":"limit reached (max 5 pinned messages per channel)"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update message
|
|
var msg messageResponse
|
|
var editedAt sql.NullString
|
|
var createdAt sql.NullString
|
|
var replyTo sql.NullString
|
|
err = h.db.QueryRowContext(r.Context(), `
|
|
UPDATE messages SET pinned = TRUE
|
|
WHERE id = $1
|
|
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
|
`, messageID).Scan(
|
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to pin message"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch author details
|
|
err = h.db.QueryRowContext(r.Context(), `
|
|
SELECT username, display_name FROM users WHERE id = $1
|
|
`, msg.AuthorID).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
|
|
|
|
// Attach embeds
|
|
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
|
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
|
|
|
// Broadcast MESSAGE_UPDATE
|
|
h.hub.BroadcastToServer(serverID, gateway.Event{
|
|
Type: gateway.EventMessageUpdate,
|
|
Data: msg,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(msg)
|
|
}
|
|
|
|
func (h *Handler) Unpin(w http.ResponseWriter, r *http.Request) {
|
|
channelID := chi.URLParam(r, "channelID")
|
|
messageID := chi.URLParam(r, "messageID")
|
|
_, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Verify the message exists and belongs to this channel
|
|
var msgChannelID string
|
|
err := h.db.QueryRowContext(r.Context(), "SELECT channel_id FROM messages WHERE id = $1", messageID).Scan(&msgChannelID)
|
|
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 msgChannelID != channelID {
|
|
http.Error(w, `{"error":"message does not belong to this channel"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Update message
|
|
var msg messageResponse
|
|
var editedAt sql.NullString
|
|
var createdAt sql.NullString
|
|
var replyTo sql.NullString
|
|
err = h.db.QueryRowContext(r.Context(), `
|
|
UPDATE messages SET pinned = FALSE
|
|
WHERE id = $1
|
|
RETURNING id, channel_id, author_id, content, reply_to::text, edited_at::text, pinned, created_at::text
|
|
`, messageID).Scan(
|
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt,
|
|
)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to unpin message"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Fetch author details
|
|
err = h.db.QueryRowContext(r.Context(), `
|
|
SELECT username, display_name FROM users WHERE id = $1
|
|
`, msg.AuthorID).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
|
|
|
|
// Attach embeds
|
|
msg.Embeds = h.attachEmbeds(r.Context(), []messageResponse{msg})[0].Embeds
|
|
msg = h.attachReactions(r.Context(), []messageResponse{msg})[0]
|
|
|
|
// Broadcast MESSAGE_UPDATE
|
|
h.hub.BroadcastToServer(serverID, gateway.Event{
|
|
Type: gateway.EventMessageUpdate,
|
|
Data: msg,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(msg)
|
|
}
|
|
|
|
func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
|
channelID := chi.URLParam(r, "channelID")
|
|
_, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
|
|
if !ok {
|
|
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.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
|
FROM messages m
|
|
JOIN users u ON m.author_id = u.id
|
|
WHERE m.channel_id = $1 AND m.pinned = TRUE
|
|
ORDER BY m.created_at DESC
|
|
`, channelID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to list pinned 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, &msg.Pinned, &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)
|
|
messages = h.attachReactions(r.Context(), messages)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(messages)
|
|
}
|