e69553af02
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
366 lines
10 KiB
Go
366 lines
10 KiB
Go
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"
|
|
)
|
|
|
|
type Handler struct {
|
|
db *sql.DB
|
|
hub *gateway.Hub
|
|
}
|
|
|
|
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
|
return &Handler{db: db, hub: hub}
|
|
}
|
|
|
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
|
r.Get("/{channelID}/messages", h.List)
|
|
r.Post("/{channelID}/messages", h.Create)
|
|
r.Patch("/{messageID}", h.Update)
|
|
r.Delete("/{messageID}", h.Delete)
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|
channelID := chi.URLParam(r, "channelID")
|
|
userID, ok := h.requireChannelAccess(w, r, channelID)
|
|
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
|
|
}
|
|
|
|
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::text, created_at::text
|
|
`, channelID, userID, req.Content).Scan(
|
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &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 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,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(msg)
|
|
}
|
|
|
|
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 {
|
|
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)
|
|
return
|
|
}
|
|
if req.Content == "" {
|
|
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
|
|
`, 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
|
|
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 {
|
|
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)
|
|
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,
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(msg)
|
|
}
|
|
|
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
SELECT author_id, channel_id FROM messages WHERE id = $1
|
|
`, messageID).Scan(&authorID, &channelID)
|
|
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
|
|
}
|
|
|
|
_, 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,
|
|
},
|
|
})
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|