diff --git a/.env.example b/.env.example index 68de2bd..a8bee82 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,23 @@ # App DUMPSTER_HOST=localhost DUMPSTER_PORT=8080 -DUMPSTER_SECRET=change...n -# Database +DUMPSTER_SECRET=*** Database POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=dumpster -POSTGRES_PASSWORD=dumpster - -# Valkey +POSTGRES_PASSWORD=*** Valkey VALKEY_URL=redis://localhost:***@example.com +# MinIO +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +LIVEKIT_API_SECRET= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@your.domain +# Integrations +GIPHY_API_KEY=your_giphy_api_key_here diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 7bfc74c..8a7ef91 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -54,10 +54,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, diff --git a/cmd/server/main.go b/cmd/server/main.go index b04d022..6555917 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "log/slog" "net/http" @@ -11,9 +12,11 @@ import ( "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/giphy" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" ) @@ -42,6 +45,18 @@ func main() { hub := gateway.NewHub(logger) go hub.Run() + // Giphy client (nil if no API key) + giphyClient := giphy.NewClient(cfg.Giphy.APIKey) + + // Upload handler (nil if no MinIO config) + uploadHandler, err := upload.NewHandler(cfg) + if err != nil { + logger.Warn("upload handler not available", "error", err) + } + + // Auth handler + authHandler := auth.NewHandler(database.DB, cfg) + r := chi.NewRouter() r.Use(chimw.Logger) r.Use(chimw.Recoverer) @@ -60,9 +75,9 @@ func main() { // API routes r.Route("/api/v1", func(r chi.Router) { - // Auth (public) + // Auth (public: register, login, logout) r.Route("/auth", func(r chi.Router) { - auth.NewHandler(database.DB, cfg).RegisterRoutes(r) + authHandler.RegisterPublicRoutes(r) }) // Protected routes @@ -70,6 +85,9 @@ func main() { r.Use(middleware.Session(sessionStore, cfg)) r.Use(middleware.RequireAuth) + // Auth (protected: me, update profile) + authHandler.RegisterProtectedRoutes(r) + // Servers r.Route("/servers", func(r chi.Router) { server.NewHandler(database.DB).RegisterRoutes(r) @@ -84,9 +102,46 @@ func main() { r.Route("/channels/{channelID}/messages", func(r chi.Router) { message.NewHandler(database.DB, hub).RegisterRoutes(r) }) + + // Giphy search + if giphyClient != nil { + r.Get("/gifs/search", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("q") + if query == "" { + http.Error(w, `{"error":"missing query"}`, http.StatusBadRequest) + return + } + gifs, err := giphyClient.Search(query, 20) + if err != nil { + http.Error(w, `{"error":"search failed"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gifs) + }) + r.Get("/gifs/trending", func(w http.ResponseWriter, r *http.Request) { + gifs, err := giphyClient.GetTrending(20) + if err != nil { + http.Error(w, `{"error":"trending failed"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gifs) + }) + } + + // File upload + if uploadHandler != nil { + r.Post("/upload", uploadHandler.Upload) + } }) }) + // File serving (public, for viewing uploaded files) + if uploadHandler != nil { + r.Get("/files/*", uploadHandler.Serve) + } + // Static file serving for production (SPA) staticDir := "/srv/web" if _, err := os.Stat(staticDir); err == nil { diff --git a/go.mod b/go.mod index e63af94..4396b66 100644 --- a/go.mod +++ b/go.mod @@ -7,14 +7,30 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 + github.com/minio/minio-go/v7 v7.2.1 golang.org/x/crypto v0.53.0 ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect ) diff --git a/go.sum b/go.sum index 3eadd49..367729c 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,11 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -15,15 +20,54 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw= +github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= @@ -31,6 +75,10 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 6e19085..406a7ab 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -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()), + }) +} diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go index 3d65329..0b1a0cc 100644 --- a/internal/channel/handlers.go +++ b/internal/channel/handlers.go @@ -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}) -} diff --git a/internal/config/config.go b/internal/config/config.go index 6ab426a..9b7f2fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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", ""), + }, } } diff --git a/internal/db/db.go b/internal/db/db.go index 8b70f97..c4c3517 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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, diff --git a/internal/giphy/client.go b/internal/giphy/client.go new file mode 100644 index 0000000..0ea75c7 --- /dev/null +++ b/internal/giphy/client.go @@ -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 +} diff --git a/internal/message/handlers.go b/internal/message/handlers.go index 93ab9a5..1a08c59 100644 --- a/internal/message/handlers.go +++ b/internal/message/handlers.go @@ -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}) -} diff --git a/internal/upload/handlers.go b/internal/upload/handlers.go new file mode 100644 index 0000000..aec8a14 --- /dev/null +++ b/internal/upload/handlers.go @@ -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) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 690a502..95fbe6f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,8 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'; import { LoginForm } from './components/LoginForm.tsx'; import { Layout } from './components/Layout.tsx'; import { ChatArea } from './components/ChatArea.tsx'; +import { UserSettings } from './components/UserSettings.tsx'; import { useAuthStore } from './stores/auth.ts'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -14,6 +15,24 @@ function App() { } /> + +
+
+ + ← [BACK] + + SETTINGS +
+
+ +
+
+ + } + /> state.fetchMessages); const sendMessage = useMessageStore((state) => state.sendMessage); const [input, setInput] = useState(''); + const [showGifPicker, setShowGifPicker] = useState(false); const bottomRef = useRef(null); useEffect(() => { @@ -39,6 +41,13 @@ export function ChatArea() { setInput(''); }; + const handleGifSelect = async (gif: Gif) => { + if (!activeChannelId) return; + const content = `![${gif.title || 'GIF'}](${gif.url})`; + await sendMessage(activeChannelId, content); + setShowGifPicker(false); + }; + return (
@@ -58,6 +67,14 @@ export function ChatArea() { ))}
+ {showGifPicker && ( +
+ setShowGifPicker(false)} + /> +
+ )}
{'>'} +
); diff --git a/web/src/components/GiphyPicker.tsx b/web/src/components/GiphyPicker.tsx new file mode 100644 index 0000000..f99c02d --- /dev/null +++ b/web/src/components/GiphyPicker.tsx @@ -0,0 +1,154 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { api } from '../lib/api'; + +interface GifImage { + url: string; +} + +interface Gif { + id: string; + title: string; + url: string; + images: { + fixed_height: GifImage; + original: GifImage; + }; + username: string; +} + +interface GiphyPickerProps { + onSelect: (gif: Gif) => void; + onClose: () => void; +} + +export function GiphyPicker({ onSelect, onClose }: GiphyPickerProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef>(); + + // Fetch trending on mount + useEffect(() => { + const fetchTrending = async () => { + setLoading(true); + try { + const gifs = await api.get('/gifs/trending'); + setResults(gifs); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }; + fetchTrending(); + inputRef.current?.focus(); + }, []); + + // Debounced search + useEffect(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + if (!query.trim()) { + // If query cleared, reload trending + const fetchTrending = async () => { + setLoading(true); + try { + const gifs = await api.get('/gifs/trending'); + setResults(gifs); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }; + fetchTrending(); + return; + } + + debounceRef.current = setTimeout(async () => { + setLoading(true); + try { + const gifs = await api.get( + `/gifs/search?q=${encodeURIComponent(query.trim())}` + ); + setResults(gifs); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }, 300); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [query]); + + const handleClick = useCallback( + (gif: Gif) => { + onSelect(gif); + }, + [onSelect] + ); + + return ( +
+ {/* Header with search */} +
+ {'>'} search: + setQuery(e.target.value)} + placeholder="_" + className="bg-transparent text-gb-fg outline-none flex-1 caret-gb-orange" + /> + +
+ + {/* Results grid */} +
+ {loading && ( +

[loading...]

+ )} + {!loading && results.length === 0 && ( +

[no results]

+ )} + {!loading && results.length > 0 && ( +
+ {results.map((gif) => ( + + ))} +
+ )} +
+
+ ); +} + +export type { Gif }; diff --git a/web/src/components/UserSettings.tsx b/web/src/components/UserSettings.tsx new file mode 100644 index 0000000..e00dfc4 --- /dev/null +++ b/web/src/components/UserSettings.tsx @@ -0,0 +1,272 @@ +import { useState, useRef, useEffect } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/auth.ts'; + +export function UserSettings() { + const { user, updateProfile, isLoading, error, clearError, fetchMe } = useAuthStore(); + const navigate = useNavigate(); + + const [displayName, setDisplayName] = useState(''); + const [bio, setBio] = useState(''); + const [statusText, setStatusText] = useState(''); + const [accentColor, setAccentColor] = useState('#fe8019'); + const [saved, setSaved] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (user) { + setDisplayName(user.display_name || ''); + setBio(user.bio || ''); + setStatusText(user.status_text || ''); + setAccentColor(user.accent_color || '#fe8019'); + } + }, [user]); + + if (!user) { + navigate('/login'); + return null; + } + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + clearError(); + setSaved(false); + + try { + await updateProfile({ + display_name: displayName, + bio, + status_text: statusText, + accent_color: accentColor, + }); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch { + // error is set in store + } + }; + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + setUploadError(null); + + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/v1/upload', { + method: 'POST', + credentials: 'include', + body: formData, + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.message || data?.error || `Upload failed: ${response.status}`); + } + + await fetchMe(); + } catch (err) { + setUploadError(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const hexValid = /^#[0-9a-fA-F]{6}$/.test(accentColor); + + return ( +
+
+ {/* Box-drawn frame */} +
+ {/* Header */} +
+            {'┌──────────────────────────────────┐\n'}
+            {'│   === PROFILE SETTINGS ===       │\n'}
+            {'└──────────────────────────────────┘'}
+          
+ +
+ {/* Avatar Section */} +
+ +
+
+ {user.avatar ? ( + avatar + ) : ( + + {user.username.charAt(0).toUpperCase()} + + )} +
+
+ + {user.avatar || 'no avatar set'} + +
+ + +
+ {uploadError && ( +

ERR: {uploadError}

+ )} +
+
+
+ + {/* Display Name */} +
+ + setDisplayName(e.target.value.slice(0, 32))} + maxLength={32} + className="terminal-input w-full" + placeholder="your display name" + /> + + {displayName.length}/32 + +
+ + {/* Bio */} +
+ +