bb5a56816b
Backend: - WebSocket gateway (hub, client, events) with fanout broadcast - Server CRUD handlers (create, list, get, update, delete) - Channel CRUD handlers (create, list, get, update, delete) - Message CRUD handlers (list with cursor pagination, create, update, delete) - cmd/migrate standalone migration CLI (up/down) - cmd/server wired to all handlers + WebSocket + static file serving Frontend: - Zustand stores: auth, server, channel, message, websocket - API client with fetch wrapper - Terminal-styled components: Layout, LoginForm, ChatArea, ChannelList, ServerBar, MemberList - React Router with login and main routes - Gruvbox dark palette throughout Ops: - Docker Compose with app service (multi-stage build) - Caddyfile with WebSocket upgrade support - Makefile for common tasks
327 lines
8.9 KiB
Go
327 lines
8.9 KiB
Go
package message
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Handler holds dependencies for message CRUD operations.
|
|
type Handler struct {
|
|
db *sql.DB
|
|
hub *gateway.Hub
|
|
}
|
|
|
|
// NewHandler creates a new message Handler.
|
|
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
|
return &Handler{db: db, hub: hub}
|
|
}
|
|
|
|
// RegisterRoutes registers message routes on the given chi.Router.
|
|
// Expects to be mounted under /channels/{channelID}/messages.
|
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
|
r.Get("/", h.List)
|
|
r.Post("/", h.Create)
|
|
r.Patch("/{messageID}", h.Update)
|
|
r.Delete("/{messageID}", h.Delete)
|
|
}
|
|
|
|
// Message is the JSON representation of a message.
|
|
type Message struct {
|
|
ID string `json:"id"`
|
|
ChannelID string `json:"channel_id"`
|
|
AuthorID string `json:"author_id"`
|
|
Content string `json:"content"`
|
|
EditedAt *string `json:"edited_at,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type createMessageRequest struct {
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type updateMessageRequest struct {
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// List handles GET / — returns messages in a channel with cursor-based pagination.
|
|
// Query params: limit (default 50, max 100), before (message ID cursor).
|
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
channelID := chi.URLParam(r, "channelID")
|
|
if _, err := uuid.Parse(channelID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid channel id")
|
|
return
|
|
}
|
|
|
|
// Verify the user is a member of the server that owns this channel
|
|
if !h.isChannelMember(r, userID, channelID) {
|
|
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
return
|
|
}
|
|
|
|
// Parse pagination 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 != "" {
|
|
if _, err := uuid.Parse(before); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid before cursor")
|
|
return
|
|
}
|
|
rows, err = h.db.QueryContext(r.Context(), `
|
|
SELECT id, channel_id, author_id, content, edited_at, created_at
|
|
FROM messages
|
|
WHERE channel_id = $1
|
|
AND created_at < (SELECT created_at FROM messages WHERE id = $2)
|
|
ORDER BY created_at DESC
|
|
LIMIT $3
|
|
`, channelID, before, limit)
|
|
} else {
|
|
rows, err = h.db.QueryContext(r.Context(), `
|
|
SELECT id, channel_id, author_id, content, edited_at, created_at
|
|
FROM messages
|
|
WHERE channel_id = $1
|
|
ORDER BY created_at DESC
|
|
LIMIT $2
|
|
`, channelID, limit)
|
|
}
|
|
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list messages")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
messages := []Message{}
|
|
for rows.Next() {
|
|
var m Message
|
|
if err := rows.Scan(&m.ID, &m.ChannelID, &m.AuthorID, &m.Content, &m.EditedAt, &m.CreatedAt); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server error")
|
|
return
|
|
}
|
|
messages = append(messages, m)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "server error")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(messages)
|
|
}
|
|
|
|
// Create handles POST / — creates a new message and broadcasts MESSAGE_CREATE.
|
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
channelID := chi.URLParam(r, "channelID")
|
|
if _, err := uuid.Parse(channelID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid channel id")
|
|
return
|
|
}
|
|
|
|
if !h.isChannelMember(r, userID, channelID) {
|
|
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
return
|
|
}
|
|
|
|
var req createMessageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
if req.Content == "" {
|
|
writeError(w, http.StatusBadRequest, "content is required")
|
|
return
|
|
}
|
|
|
|
var msg Message
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
INSERT INTO messages (channel_id, author_id, content)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, channel_id, author_id, content, edited_at, created_at
|
|
`, channelID, userID, req.Content).Scan(
|
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create message")
|
|
return
|
|
}
|
|
|
|
// Broadcast MESSAGE_CREATE event via the gateway hub
|
|
h.hub.BroadcastEvent(gateway.Event{
|
|
Type: gateway.EventMessageCreate,
|
|
Data: msg,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(msg)
|
|
}
|
|
|
|
// Update handles PATCH /{messageID} — updates a message (author only).
|
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
channelID := chi.URLParam(r, "channelID")
|
|
messageID := chi.URLParam(r, "messageID")
|
|
if _, err := uuid.Parse(messageID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid message id")
|
|
return
|
|
}
|
|
|
|
var req updateMessageRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
if req.Content == "" {
|
|
writeError(w, http.StatusBadRequest, "content is required")
|
|
return
|
|
}
|
|
|
|
// Verify authorship
|
|
var authorID string
|
|
err := h.db.QueryRowContext(r.Context(),
|
|
`SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`,
|
|
messageID, channelID,
|
|
).Scan(&authorID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "message not found")
|
|
return
|
|
}
|
|
if authorID != userID {
|
|
writeError(w, http.StatusForbidden, "you can only edit your own messages")
|
|
return
|
|
}
|
|
|
|
var msg Message
|
|
err = h.db.QueryRowContext(r.Context(), `
|
|
UPDATE messages SET content = $1, edited_at = NOW()
|
|
WHERE id = $2 AND channel_id = $3
|
|
RETURNING id, channel_id, author_id, content, edited_at, created_at
|
|
`, req.Content, messageID, channelID).Scan(
|
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to update message")
|
|
return
|
|
}
|
|
|
|
// Broadcast MESSAGE_UPDATE event
|
|
h.hub.BroadcastEvent(gateway.Event{
|
|
Type: gateway.EventMessageUpdate,
|
|
Data: msg,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(msg)
|
|
}
|
|
|
|
// Delete handles DELETE /{messageID} — deletes a message (author only).
|
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
|
|
channelID := chi.URLParam(r, "channelID")
|
|
messageID := chi.URLParam(r, "messageID")
|
|
if _, err := uuid.Parse(messageID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid message id")
|
|
return
|
|
}
|
|
|
|
// Verify authorship
|
|
var authorID string
|
|
err := h.db.QueryRowContext(r.Context(),
|
|
`SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`,
|
|
messageID, channelID,
|
|
).Scan(&authorID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "message not found")
|
|
return
|
|
}
|
|
if authorID != userID {
|
|
writeError(w, http.StatusForbidden, "you can only delete your own messages")
|
|
return
|
|
}
|
|
|
|
result, err := h.db.ExecContext(r.Context(),
|
|
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
|
|
messageID, channelID,
|
|
)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete message")
|
|
return
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
writeError(w, http.StatusNotFound, "message not found")
|
|
return
|
|
}
|
|
|
|
// Broadcast MESSAGE_DELETE event
|
|
h.hub.BroadcastEvent(gateway.Event{
|
|
Type: gateway.EventMessageDelete,
|
|
Data: map[string]string{
|
|
"id": messageID,
|
|
"channel_id": channelID,
|
|
},
|
|
})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// isChannelMember checks if a user is a member of the server that owns the channel.
|
|
func (h *Handler) isChannelMember(r *http.Request, userID, channelID string) bool {
|
|
var exists bool
|
|
h.db.QueryRowContext(r.Context(), `
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM members m
|
|
INNER JOIN channels c ON c.server_id = m.server_id
|
|
WHERE m.user_id = $1 AND c.id = $2
|
|
)
|
|
`, userID, channelID).Scan(&exists)
|
|
return exists
|
|
}
|
|
|
|
// writeError sends a JSON error response.
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
|
}
|