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
+162 -156
View File
@@ -1,27 +1,24 @@
package channel
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// Handler holds the database dependency for channel CRUD operations.
type Handler struct {
db *sql.DB
}
// NewHandler creates a new channel Handler.
func NewHandler(db *sql.DB) *Handler {
return &Handler{db: db}
}
// RegisterRoutes registers channel routes on the given chi.Router.
// Expects to be mounted under /servers/{serverID}/channels.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/", h.Create)
r.Get("/", h.List)
@@ -30,8 +27,15 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Delete("/{channelID}", h.Delete)
}
// Channel is the JSON representation of a channel.
type Channel struct {
type createChannelRequest struct {
ServerID string `json:"server_id"`
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position *int `json:"position"`
}
type channelResponse struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
Name string `json:"name"`
@@ -41,97 +45,102 @@ type Channel struct {
CreatedAt string `json:"created_at"`
}
type createChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Category string `json:"category"`
Position *int `json:"position,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 updateChannelRequest struct {
Name *string `json:"name,omitempty"`
Type *string `json:"type,omitempty"`
Category *string `json:"category,omitempty"`
Position *int `json:"position,omitempty"`
// 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
}
// Create handles POST / — creates a new channel in a server.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
serverID := chi.URLParam(r, "serverID")
if _, err := uuid.Parse(serverID); err != nil {
writeError(w, http.StatusBadRequest, "invalid server id")
return
}
// Verify membership
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req createChannelRequest
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.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
if req.ServerID == "" || req.Name == "" {
http.Error(w, `{"error":"server_id and name are required"}`, http.StatusBadRequest)
return
}
if req.Type == "" {
req.Type = "text"
}
if req.Type != "text" && req.Type != "voice" {
writeError(w, http.StatusBadRequest, "type must be text or voice")
return
}
if req.Category == "" {
req.Category = "General"
}
member, err := h.isMember(r.Context(), userID, req.ServerID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !member {
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
return
}
channelType := req.Type
if channelType == "" {
channelType = "text"
}
category := req.Category
if category == "" {
category = "general"
}
position := 0
if req.Position != nil {
position = *req.Position
}
var channelID string
err := h.db.QueryRowContext(r.Context(), `
var ch channelResponse
err = h.db.QueryRowContext(r.Context(), `
INSERT INTO channels (server_id, name, type, category, position)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, serverID, req.Name, req.Type, req.Category, position).Scan(&channelID)
RETURNING id, server_id, name, type, category, position, created_at
`, req.ServerID, req.Name, channelType, category, position).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create channel")
http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": channelID})
json.NewEncoder(w).Encode(ch)
}
// List handles GET / — returns all channels in a server.
func (h *Handler) List(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
}
serverID := chi.URLParam(r, "serverID")
if _, err := uuid.Parse(serverID); err != nil {
writeError(w, http.StatusBadRequest, "invalid server id")
serverID := r.URL.Query().Get("server_id")
if serverID == "" {
http.Error(w, `{"error":"server_id query parameter is required"}`, http.StatusBadRequest)
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
member, err := h.isMember(r.Context(), userID, serverID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !member {
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
return
}
@@ -139,26 +148,25 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
SELECT id, server_id, name, type, category, position, created_at
FROM channels
WHERE server_id = $1
ORDER BY position ASC, created_at ASC
ORDER BY position, name
`, serverID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list channels")
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
channels := []Channel{}
channels := make([]channelResponse, 0)
for rows.Next() {
var c Channel
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt); err != nil {
writeError(w, http.StatusInternalServerError, "server error")
var ch channelResponse
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt); err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
channels = append(channels, c)
channels = append(channels, ch)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, "server error")
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
@@ -166,153 +174,151 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(channels)
}
// Get handles GET /{channelID} — returns a single channel.
func (h *Handler) Get(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
}
serverID := chi.URLParam(r, "serverID")
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
var c Channel
var ch channelResponse
err := h.db.QueryRowContext(r.Context(), `
SELECT id, server_id, name, type, category, position, created_at
FROM channels
WHERE id = $1 AND server_id = $2
`, channelID, serverID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt)
FROM channels WHERE id = $1
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt)
if err != nil {
writeError(w, http.StatusNotFound, "channel not found")
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
member, err := h.isMember(r.Context(), userID, ch.ServerID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !member {
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(c)
json.NewEncoder(w).Encode(ch)
}
type updateChannelRequest struct {
Name *string `json:"name"`
Type *string `json:"type"`
Category *string `json:"category"`
Position *int `json:"position"`
}
// Update handles PATCH /{channelID} — updates a channel's properties.
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
}
serverID := chi.URLParam(r, "serverID")
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
var req updateChannelRequest
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.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil {
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
return
}
// Validate type if provided
if req.Type != nil && *req.Type != "text" && *req.Type != "voice" {
writeError(w, http.StatusBadRequest, "type must be text or voice")
// Look up server ownership and verify membership
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)
return
}
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
result, err := h.db.ExecContext(r.Context(), `
UPDATE channels SET
name = COALESCE($1, name),
member, err := h.isMember(r.Context(), userID, serverID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !member {
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
return
}
var ch channelResponse
err = h.db.QueryRowContext(r.Context(), `
UPDATE channels
SET name = COALESCE($1, name),
type = COALESCE($2, type),
category = COALESCE($3, category),
position = COALESCE($4, position)
WHERE id = $5 AND server_id = $6
`, req.Name, req.Type, req.Category, req.Position, channelID, serverID)
WHERE id = $5
RETURNING id, server_id, name, type, category, position, created_at
`, req.Name, req.Type, req.Category, req.Position, channelID).Scan(
&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update channel")
http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError)
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeError(w, http.StatusNotFound, "channel not found")
return
}
var c Channel
h.db.QueryRowContext(r.Context(), `
SELECT id, server_id, name, type, category, position, created_at
FROM channels WHERE id = $1
`, channelID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(c)
json.NewEncoder(w).Encode(ch)
}
// Delete handles DELETE /{channelID} — deletes a channel.
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
}
serverID := chi.URLParam(r, "serverID")
channelID := chi.URLParam(r, "channelID")
if _, err := uuid.Parse(channelID); err != nil {
writeError(w, http.StatusBadRequest, "invalid channel id")
return
}
if !h.isMember(r, userID, serverID) {
writeError(w, http.StatusForbidden, "not a member of this server")
return
}
result, err := h.db.ExecContext(r.Context(),
`DELETE FROM channels WHERE id = $1 AND server_id = $2`,
channelID, serverID,
)
serverID, err := h.serverIDForChannel(r.Context(), channelID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete channel")
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
return
}
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
writeError(w, http.StatusNotFound, "channel not found")
// Only the server owner can delete channels
var ownerID string
err = h.db.QueryRowContext(r.Context(), `
SELECT owner_id FROM servers WHERE id = $1
`, serverID).Scan(&ownerID)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if ownerID != userID {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
_, err = h.db.ExecContext(r.Context(), `
DELETE FROM channels WHERE id = $1
`, channelID)
if err != nil {
http.Error(w, `{"error":"failed to delete channel"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// isMember checks if a user is a member of the given server.
func (h *Handler) isMember(r *http.Request, userID, serverID string) bool {
var exists bool
h.db.QueryRowContext(r.Context(),
`SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)`,
userID, serverID,
).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})
}