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:
+136
-36
@@ -1,19 +1,23 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
cfg *config.Config
|
||||
db *sql.DB
|
||||
cfg *config.Config
|
||||
sessions *SessionStore
|
||||
}
|
||||
|
||||
@@ -25,17 +29,22 @@ func NewHandler(db *sql.DB, cfg *config.Config) *Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
func (h *Handler) RegisterPublicRoutes(r chi.Router) {
|
||||
r.Post("/register", h.Register)
|
||||
r.Post("/login/password", h.Login)
|
||||
r.Post("/logout", h.Logout)
|
||||
r.Get("/me", h.Me)
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
||||
r.Get("/auth/me", h.Me)
|
||||
r.Patch("/auth/me", h.UpdateProfile)
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -43,6 +52,32 @@ type loginRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Bio string `json:"bio"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
StatusText string `json:"status_text"`
|
||||
}
|
||||
|
||||
type UserProfile struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func gravatarURL(email string) string {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
hash := md5.Sum([]byte(email))
|
||||
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?d=identicon&s=256", hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -61,12 +96,17 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
displayName := req.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = req.Username
|
||||
}
|
||||
|
||||
var userID string
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO users (username, email, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO users (username, display_name, email, password_hash, avatar)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, req.Username, req.Email, hash).Scan(&userID)
|
||||
`, req.Username, displayName, req.Email, hash, gravatarURL(req.Email)).Scan(&userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"user exists"}`, http.StatusConflict)
|
||||
return
|
||||
@@ -78,16 +118,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: h.cfg.Session.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.Session.Duration.Seconds()),
|
||||
})
|
||||
|
||||
h.setSessionCookie(w, token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||
}
|
||||
@@ -120,16 +151,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: h.cfg.Session.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.Session.Duration.Seconds()),
|
||||
})
|
||||
|
||||
h.setSessionCookie(w, token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||
}
|
||||
@@ -152,10 +174,88 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
// Placeholder; requires session middleware integration
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": ""})
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
var _ = uuid.New()
|
||||
var _ = errors.New("placeholder")
|
||||
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Bio) > 250 {
|
||||
http.Error(w, `{"error":"bio too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.StatusText) > 128 {
|
||||
http.Error(w, `{"error":"status text too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.DisplayName) > 32 {
|
||||
http.Error(w, `{"error":"display name too long"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.AccentColor != "" && len(req.AccentColor) != 7 {
|
||||
http.Error(w, `{"error":"accent color must be 7 char hex"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
UPDATE users
|
||||
SET display_name = $2, bio = $3, accent_color = $4, status_text = $5
|
||||
WHERE id = $1
|
||||
`, userID, req.DisplayName, req.Bio, req.AccentColor, req.StatusText)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.getProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
func (h *Handler) getProfile(ctx context.Context, userID string) (UserProfile, error) {
|
||||
var user UserProfile
|
||||
return user, h.db.QueryRowContext(ctx, `
|
||||
SELECT id, username, display_name, email, avatar, bio, accent_color, status, status_text, created_at
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&user.ID, &user.Username, &user.DisplayName, &user.Email, &user.Avatar, &user.Bio, &user.AccentColor, &user.Status, &user.StatusText, &user.CreatedAt)
|
||||
}
|
||||
|
||||
func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: h.cfg.Session.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
MaxAge: int(h.cfg.Session.Duration.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
+162
-156
@@ -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})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ type Config struct {
|
||||
Port string
|
||||
Database DatabaseConfig
|
||||
Valkey ValkeyConfig
|
||||
MinIO MinIOConfig
|
||||
LiveKit LiveKitConfig
|
||||
WebPush WebPushConfig
|
||||
Session SessionConfig
|
||||
Giphy GiphyConfig
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -27,6 +31,30 @@ type ValkeyConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
type MinIOConfig struct {
|
||||
Endpoint string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
Bucket string
|
||||
UseSSL bool
|
||||
}
|
||||
|
||||
type LiveKitConfig struct {
|
||||
URL string
|
||||
APIKey string
|
||||
Secret string
|
||||
}
|
||||
|
||||
type WebPushConfig struct {
|
||||
PublicKey string
|
||||
PrivateKey string
|
||||
Subject string
|
||||
}
|
||||
|
||||
type GiphyConfig struct {
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type SessionConfig struct {
|
||||
CookieName string
|
||||
Duration time.Duration
|
||||
@@ -46,10 +74,20 @@ func Load() *Config {
|
||||
Valkey: ValkeyConfig{
|
||||
URL: getEnv("VALKEY_URL", "redis://localhost:***@example.com"),
|
||||
},
|
||||
MinIO: MinIOConfig{
|
||||
Endpoint: getEnv("MINIO_ENDPOINT", ""),
|
||||
AccessKey: getEnv("MINIO_ACCESS_KEY", ""),
|
||||
SecretKey: getEnv("MINIO_SECRET_KEY", ""),
|
||||
Bucket: getEnv("MINIO_BUCKET", "dumpster-files"),
|
||||
UseSSL: getBool("MINIO_USE_SSL", false),
|
||||
},
|
||||
Session: SessionConfig{
|
||||
CookieName: getEnv("DUMPSTER_SESSION_COOKIE", "dumpster_session"),
|
||||
Duration: time.Duration(getInt("DUMPSTER_SESSION_DAYS", 30)) * 24 * time.Hour,
|
||||
},
|
||||
Giphy: GiphyConfig{
|
||||
APIKey: getEnv("GIPHY_API_KEY", ""),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,15 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255),
|
||||
avatar TEXT,
|
||||
bio VARCHAR(250) DEFAULT '',
|
||||
accent_color VARCHAR(7) DEFAULT '',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
||||
status_text VARCHAR(128) DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package giphy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const apiBase = "https://api.giphy.com/v1/gifs"
|
||||
|
||||
type Client struct {
|
||||
apiKey string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewClient(apiKey string) *Client {
|
||||
if apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
URL string `json:"url"`
|
||||
Width string `json:"width"`
|
||||
Height string `json:"height"`
|
||||
}
|
||||
|
||||
type Images struct {
|
||||
Original Image `json:"original"`
|
||||
FixedHeight Image `json:"fixed_height"`
|
||||
FixedWidth Image `json:"fixed_width"`
|
||||
}
|
||||
|
||||
type Gif struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Slug string `json:"slug"`
|
||||
Images Images `json:"images"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
Data []Gif `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) Search(query string, limit int) ([]Gif, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("giphy client not configured")
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
u, err := url.Parse(apiBase + "/search")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("api_key", c.apiKey)
|
||||
q.Set("q", query)
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
q.Set("rating", "pg-13")
|
||||
q.Set("lang", "en")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest("GET", u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("giphy API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var sr SearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sr.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTrending(limit int) ([]Gif, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("giphy client not configured")
|
||||
}
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
u, err := url.Parse(apiBase + "/trending")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("api_key", c.apiKey)
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
q.Set("rating", "pg-13")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
resp, err := c.client.Get(u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("giphy API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var sr SearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sr.Data, nil
|
||||
}
|
||||
+185
-146
@@ -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})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
func NewHandler(cfg *config.Config) (*Handler, error) {
|
||||
endpoint := cfg.MinIO.Endpoint
|
||||
if endpoint == "" {
|
||||
return nil, fmt.Errorf("minio endpoint not configured")
|
||||
}
|
||||
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.MinIO.AccessKey, cfg.MinIO.SecretKey, ""),
|
||||
Secure: cfg.MinIO.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := client.BucketExists(ctx, cfg.MinIO.Bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bucket check: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
if err := client.MakeBucket(ctx, cfg.MinIO.Bucket, minio.MakeBucketOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("make bucket: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
client: client,
|
||||
bucket: cfg.MinIO.Bucket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(50 << 20); err != nil {
|
||||
http.Error(w, `{"error":"invalid form"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
ext := ""
|
||||
if idx := strings.LastIndex(header.Filename, "."); idx >= 0 {
|
||||
ext = header.Filename[idx:]
|
||||
}
|
||||
|
||||
objectName := uuid.New().String() + ext
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info, err := h.client.PutObject(ctx, h.bucket, objectName, file, header.Size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"upload failed"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"url":"/%s/%s","size":%d}`, h.bucket, objectName, info.Size)
|
||||
}
|
||||
|
||||
func (h *Handler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
objectName := strings.TrimPrefix(r.URL.Path, "/files/")
|
||||
if objectName == "" {
|
||||
http.Error(w, `{"error":"missing file"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
obj, err := h.client.GetObject(ctx, h.bucket, objectName, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
stat, err := obj.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", stat.ContentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size))
|
||||
io.Copy(w, obj)
|
||||
}
|
||||
Reference in New Issue
Block a user