Profiles, Giphy, uploads, settings UI

Backend:
- Auth: split routes into RegisterPublicRoutes + RegisterProtectedRoutes
- Auth: PATCH /me for profile updates (display_name, bio, accent_color, status_text)
- Auth: Gravatar fallback for avatars on register
- DB: users table now has bio, accent_color, status_text columns
- giphy/client.go: Search() and GetTrending() against Giphy API
- upload/handlers.go: MinIO file upload + serve
- config: added GiphyConfig and MinIO config
- cmd/server: wired giphy (/gifs/search, /gifs/trending), upload (/upload), files (/files/*) routes

Frontend:
- auth store: updated User interface with new profile fields, added updateProfile()
- UserSettings.tsx: terminal-styled profile editor (display_name, bio, accent_color, status_text, avatar upload)
- GiphyPicker.tsx: terminal-styled GIF picker with search + trending
- ChatArea.tsx: integrated [GIF] button into message input
- App.tsx: imports UserSettings, added /settings route
This commit is contained in:
2026-06-28 16:06:08 -04:00
parent bb5a56816b
commit e69553af02
18 changed files with 1422 additions and 352 deletions
+185 -146
View File
@@ -1,130 +1,173 @@
package message
import (
"context"
"database/sql"
"encoding/json"
"errors"
"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.Get("/{channelID}/messages", h.List)
r.Post("/{channelID}/messages", 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 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"`
EditedAt *string `json:"edited_at"`
CreatedAt string `json:"created_at"`
}
type createMessageRequest struct {
Content string `json:"content"`
// 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 updateMessageRequest struct {
Content string `json:"content"`
// 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
}
// 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) {
// 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 {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
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 _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
if _, ok := h.requireChannelAccess(w, r, channelID); !ok {
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
// 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 != "" {
if _, err := uuid.Parse(before); err != nil {
writeError(w, http.StatusBadRequest, "invalid before cursor")
// 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 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
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, before, limit)
`, channelID, cursorTime.String, 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
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 {
writeError(w, http.StatusInternalServerError, "failed to list messages")
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
messages := []Message{}
messages := make([]messageResponse, 0)
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")
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
}
messages = append(messages, m)
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
messages = append(messages, msg)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "server error")
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
@@ -132,49 +175,57 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(messages)
}
// Create handles POST / — creates a new message and broadcasts MESSAGE_CREATE.
type createMessageRequest struct {
Content string `json:"content"`
}
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")
userID, ok := h.requireChannelAccess(w, r, channelID)
if !ok {
return
}
var req createMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest)
return
}
var msg Message
var msg messageResponse
var editedAt sql.NullString
var createdAt sql.NullString
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
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
`, channelID, userID, req.Content).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt,
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create message")
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
return
}
// Broadcast MESSAGE_CREATE event via the gateway hub
// 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 editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Broadcast MESSAGE_CREATE event via WebSocket
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventMessageCreate,
Data: msg,
@@ -185,60 +236,78 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(msg)
}
// Update handles PATCH /{messageID} — updates a message (author only).
type updateMessageRequest struct {
Content string `json:"content"`
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
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")
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if req.Content == "" {
writeError(w, http.StatusBadRequest, "content is required")
http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest)
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)
err := h.db.QueryRowContext(r.Context(), `
SELECT author_id FROM messages WHERE id = $1
`, messageID).Scan(&authorID)
if err != nil {
writeError(w, http.StatusNotFound, "message not found")
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 {
writeError(w, http.StatusForbidden, "you can only edit your own messages")
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
var msg Message
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 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,
UPDATE messages
SET content = $1, edited_at = NOW()
WHERE id = $2
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
`, req.Content, messageID).Scan(
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update message")
http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError)
return
}
// Broadcast MESSAGE_UPDATE event
// 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)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if editedAt.Valid {
msg.EditedAt = &editedAt.String
}
msg.CreatedAt = createdAt.String
// Broadcast MESSAGE_UPDATE event via WebSocket
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventMessageUpdate,
Data: msg,
@@ -248,52 +317,42 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
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")
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
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)
// Verify authorship and get channel_id for the broadcast
var authorID, channelID string
err := h.db.QueryRowContext(r.Context(), `
SELECT author_id, channel_id FROM messages WHERE id = $1
`, messageID).Scan(&authorID, &channelID)
if err != nil {
writeError(w, http.StatusNotFound, "message not found")
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 {
writeError(w, http.StatusForbidden, "you can only delete your own messages")
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
result, err := h.db.ExecContext(r.Context(),
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
messageID, channelID,
)
_, err = h.db.ExecContext(r.Context(), `
DELETE FROM messages WHERE id = $1
`, messageID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete message")
http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError)
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeError(w, http.StatusNotFound, "message not found")
return
}
// Broadcast MESSAGE_DELETE event
// Broadcast MESSAGE_DELETE event via WebSocket
h.hub.BroadcastEvent(gateway.Event{
Type: gateway.EventMessageDelete,
Data: map[string]string{
@@ -304,23 +363,3 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
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})
}