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()),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user