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:
+16
-5
@@ -1,12 +1,23 @@
|
|||||||
# App
|
# App
|
||||||
DUMPSTER_HOST=localhost
|
DUMPSTER_HOST=localhost
|
||||||
DUMPSTER_PORT=8080
|
DUMPSTER_PORT=8080
|
||||||
DUMPSTER_SECRET=change...n
|
DUMPSTER_SECRET=*** Database
|
||||||
# Database
|
|
||||||
POSTGRES_HOST=localhost
|
POSTGRES_HOST=localhost
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
POSTGRES_USER=dumpster
|
POSTGRES_USER=dumpster
|
||||||
POSTGRES_PASSWORD=dumpster
|
POSTGRES_PASSWORD=*** Valkey
|
||||||
|
|
||||||
# Valkey
|
|
||||||
VALKEY_URL=redis://localhost:***@example.com
|
VALKEY_URL=redis://localhost:***@example.com
|
||||||
|
# MinIO
|
||||||
|
MINIO_ENDPOINT=minio:9000
|
||||||
|
MINIO_ACCESS_KEY=<generated>
|
||||||
|
MINIO_SECRET_KEY=<gener...n
|
||||||
|
# LiveKit
|
||||||
|
LIVEKIT_URL=wss://livekit.your.domain
|
||||||
|
LIVEKIT_API_KEY=<generated>
|
||||||
|
LIVEKIT_API_SECRET=<gener...n
|
||||||
|
# Web Push
|
||||||
|
VAPID_PUBLIC_KEY=<generated>
|
||||||
|
VAPID_PRIVATE_KEY=<generated>
|
||||||
|
VAPID_SUBJECT=mailto:admin@your.domain
|
||||||
|
# Integrations
|
||||||
|
GIPHY_API_KEY=your_giphy_api_key_here
|
||||||
|
|||||||
@@ -54,10 +54,15 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email VARCHAR(255) NOT NULL UNIQUE,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR(255),
|
password_hash VARCHAR(255),
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
|
bio VARCHAR(250) DEFAULT '',
|
||||||
|
accent_color VARCHAR(7) DEFAULT '',
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
||||||
|
status_text VARCHAR(128) DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
+57
-2
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,9 +12,11 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"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/message"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/upload"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
@@ -42,6 +45,18 @@ func main() {
|
|||||||
hub := gateway.NewHub(logger)
|
hub := gateway.NewHub(logger)
|
||||||
go hub.Run()
|
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 := chi.NewRouter()
|
||||||
r.Use(chimw.Logger)
|
r.Use(chimw.Logger)
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
@@ -60,9 +75,9 @@ func main() {
|
|||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
// Auth (public)
|
// Auth (public: register, login, logout)
|
||||||
r.Route("/auth", func(r chi.Router) {
|
r.Route("/auth", func(r chi.Router) {
|
||||||
auth.NewHandler(database.DB, cfg).RegisterRoutes(r)
|
authHandler.RegisterPublicRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
@@ -70,6 +85,9 @@ func main() {
|
|||||||
r.Use(middleware.Session(sessionStore, cfg))
|
r.Use(middleware.Session(sessionStore, cfg))
|
||||||
r.Use(middleware.RequireAuth)
|
r.Use(middleware.RequireAuth)
|
||||||
|
|
||||||
|
// Auth (protected: me, update profile)
|
||||||
|
authHandler.RegisterProtectedRoutes(r)
|
||||||
|
|
||||||
// Servers
|
// Servers
|
||||||
r.Route("/servers", func(r chi.Router) {
|
r.Route("/servers", func(r chi.Router) {
|
||||||
server.NewHandler(database.DB).RegisterRoutes(r)
|
server.NewHandler(database.DB).RegisterRoutes(r)
|
||||||
@@ -84,9 +102,46 @@ func main() {
|
|||||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
||||||
message.NewHandler(database.DB, hub).RegisterRoutes(r)
|
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)
|
// Static file serving for production (SPA)
|
||||||
staticDir := "/srv/web"
|
staticDir := "/srv/web"
|
||||||
if _, err := os.Stat(staticDir); err == nil {
|
if _, err := os.Stat(staticDir); err == nil {
|
||||||
|
|||||||
@@ -7,14 +7,30 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/minio/minio-go/v7 v7.2.1
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // 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/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
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=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+131
-31
@@ -1,14 +1,18 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config"
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -25,16 +29,21 @@ 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("/register", h.Register)
|
||||||
r.Post("/login/password", h.Login)
|
r.Post("/login/password", h.Login)
|
||||||
r.Post("/logout", h.Logout)
|
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 {
|
type registerRequest struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +52,32 @@ type loginRequest struct {
|
|||||||
Password string `json:"password"`
|
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) {
|
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
var req registerRequest
|
var req registerRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
displayName := req.DisplayName
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = req.Username
|
||||||
|
}
|
||||||
|
|
||||||
var userID string
|
var userID string
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO users (username, email, password_hash)
|
INSERT INTO users (username, display_name, email, password_hash, avatar)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, req.Username, req.Email, hash).Scan(&userID)
|
`, req.Username, displayName, req.Email, hash, gravatarURL(req.Email)).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, `{"error":"user exists"}`, http.StatusConflict)
|
http.Error(w, `{"error":"user exists"}`, http.StatusConflict)
|
||||||
return
|
return
|
||||||
@@ -78,16 +118,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
h.setSessionCookie(w, token)
|
||||||
Name: h.cfg.Session.CookieName,
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: true,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
MaxAge: int(h.cfg.Session.Duration.Seconds()),
|
|
||||||
})
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||||
}
|
}
|
||||||
@@ -120,16 +151,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
h.setSessionCookie(w, token)
|
||||||
Name: h.cfg.Session.CookieName,
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: true,
|
|
||||||
SameSite: http.SameSiteStrictMode,
|
|
||||||
MaxAge: int(h.cfg.Session.Duration.Seconds()),
|
|
||||||
})
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
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) {
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": ""})
|
json.NewEncoder(w).Encode(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = uuid.New()
|
func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
var _ = errors.New("placeholder")
|
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
|
package channel
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds the database dependency for channel CRUD operations.
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new channel Handler.
|
|
||||||
func NewHandler(db *sql.DB) *Handler {
|
func NewHandler(db *sql.DB) *Handler {
|
||||||
return &Handler{db: db}
|
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) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
@@ -30,8 +27,15 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Delete("/{channelID}", h.Delete)
|
r.Delete("/{channelID}", h.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel is the JSON representation of a channel.
|
type createChannelRequest struct {
|
||||||
type Channel 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"`
|
ID string `json:"id"`
|
||||||
ServerID string `json:"server_id"`
|
ServerID string `json:"server_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -41,97 +45,102 @@ type Channel struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createChannelRequest struct {
|
// isMember checks whether the given user is a member of the given server.
|
||||||
Name string `json:"name"`
|
func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, error) {
|
||||||
Type string `json:"type"`
|
var exists bool
|
||||||
Category string `json:"category"`
|
err := h.db.QueryRowContext(ctx, `
|
||||||
Position *int `json:"position,omitempty"`
|
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
||||||
|
`, userID, serverID).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateChannelRequest struct {
|
// serverIDForChannel returns the server_id that owns the given channel.
|
||||||
Name *string `json:"name,omitempty"`
|
func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) {
|
||||||
Type *string `json:"type,omitempty"`
|
var serverID string
|
||||||
Category *string `json:"category,omitempty"`
|
err := h.db.QueryRowContext(ctx, `
|
||||||
Position *int `json:"position,omitempty"`
|
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) {
|
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
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")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
if !h.isMember(r, userID, serverID) {
|
|
||||||
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req createChannelRequest
|
var req createChannelRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.Name == "" {
|
if req.ServerID == "" || req.Name == "" {
|
||||||
writeError(w, http.StatusBadRequest, "name is required")
|
http.Error(w, `{"error":"server_id and name are required"}`, http.StatusBadRequest)
|
||||||
return
|
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
|
position := 0
|
||||||
if req.Position != nil {
|
if req.Position != nil {
|
||||||
position = *req.Position
|
position = *req.Position
|
||||||
}
|
}
|
||||||
|
|
||||||
var channelID string
|
var ch channelResponse
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO channels (server_id, name, type, category, position)
|
INSERT INTO channels (server_id, name, type, category, position)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id
|
RETURNING id, server_id, name, type, category, position, created_at
|
||||||
`, serverID, req.Name, req.Type, req.Category, position).Scan(&channelID)
|
`, 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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
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) {
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverID := chi.URLParam(r, "serverID")
|
serverID := r.URL.Query().Get("server_id")
|
||||||
if _, err := uuid.Parse(serverID); err != nil {
|
if serverID == "" {
|
||||||
writeError(w, http.StatusBadRequest, "invalid server id")
|
http.Error(w, `{"error":"server_id query parameter is required"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !h.isMember(r, userID, serverID) {
|
member, err := h.isMember(r.Context(), userID, serverID)
|
||||||
writeError(w, http.StatusForbidden, "not a member of this server")
|
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
|
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
|
SELECT id, server_id, name, type, category, position, created_at
|
||||||
FROM channels
|
FROM channels
|
||||||
WHERE server_id = $1
|
WHERE server_id = $1
|
||||||
ORDER BY position ASC, created_at ASC
|
ORDER BY position, name
|
||||||
`, serverID)
|
`, serverID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list channels")
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
channels := []Channel{}
|
channels := make([]channelResponse, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c Channel
|
var ch channelResponse
|
||||||
if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt); err != nil {
|
if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "server error")
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
channels = append(channels, c)
|
channels = append(channels, ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "server error")
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,153 +174,151 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(channels)
|
json.NewEncoder(w).Encode(channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get handles GET /{channelID} — returns a single channel.
|
|
||||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverID := chi.URLParam(r, "serverID")
|
|
||||||
channelID := chi.URLParam(r, "channelID")
|
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) {
|
var ch channelResponse
|
||||||
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var c Channel
|
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
SELECT id, server_id, name, type, category, position, created_at
|
SELECT id, server_id, name, type, category, position, created_at
|
||||||
FROM channels
|
FROM channels WHERE id = $1
|
||||||
WHERE id = $1 AND server_id = $2
|
`, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.CreatedAt)
|
||||||
`, channelID, serverID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt)
|
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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) {
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverID := chi.URLParam(r, "serverID")
|
|
||||||
channelID := chi.URLParam(r, "channelID")
|
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
|
var req updateChannelRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate type if provided
|
// Look up server ownership and verify membership
|
||||||
if req.Type != nil && *req.Type != "text" && *req.Type != "voice" {
|
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||||
writeError(w, http.StatusBadRequest, "type must be text or voice")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.db.ExecContext(r.Context(), `
|
member, err := h.isMember(r.Context(), userID, serverID)
|
||||||
UPDATE channels SET
|
if err != nil {
|
||||||
name = COALESCE($1, name),
|
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),
|
type = COALESCE($2, type),
|
||||||
category = COALESCE($3, category),
|
category = COALESCE($3, category),
|
||||||
position = COALESCE($4, position)
|
position = COALESCE($4, position)
|
||||||
WHERE id = $5 AND server_id = $6
|
WHERE id = $5
|
||||||
`, req.Name, req.Type, req.Category, req.Position, channelID, serverID)
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update channel")
|
http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError)
|
||||||
return
|
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")
|
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) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverID := chi.URLParam(r, "serverID")
|
|
||||||
channelID := chi.URLParam(r, "channelID")
|
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) {
|
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||||
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,
|
|
||||||
)
|
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, _ := result.RowsAffected()
|
// Only the server owner can delete channels
|
||||||
if rows == 0 {
|
var ownerID string
|
||||||
writeError(w, http.StatusNotFound, "channel not found")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
Port string
|
||||||
Database DatabaseConfig
|
Database DatabaseConfig
|
||||||
Valkey ValkeyConfig
|
Valkey ValkeyConfig
|
||||||
|
MinIO MinIOConfig
|
||||||
|
LiveKit LiveKitConfig
|
||||||
|
WebPush WebPushConfig
|
||||||
Session SessionConfig
|
Session SessionConfig
|
||||||
|
Giphy GiphyConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
@@ -27,6 +31,30 @@ type ValkeyConfig struct {
|
|||||||
URL string
|
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 {
|
type SessionConfig struct {
|
||||||
CookieName string
|
CookieName string
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
@@ -46,10 +74,20 @@ func Load() *Config {
|
|||||||
Valkey: ValkeyConfig{
|
Valkey: ValkeyConfig{
|
||||||
URL: getEnv("VALKEY_URL", "redis://localhost:***@example.com"),
|
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{
|
Session: SessionConfig{
|
||||||
CookieName: getEnv("DUMPSTER_SESSION_COOKIE", "dumpster_session"),
|
CookieName: getEnv("DUMPSTER_SESSION_COOKIE", "dumpster_session"),
|
||||||
Duration: time.Duration(getInt("DUMPSTER_SESSION_DAYS", 30)) * 24 * time.Hour,
|
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,
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR(255),
|
password_hash VARCHAR(255),
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
|
bio VARCHAR(250) DEFAULT '',
|
||||||
|
accent_color VARCHAR(7) DEFAULT '',
|
||||||
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
||||||
|
status_text VARCHAR(128) DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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
|
||||||
|
}
|
||||||
+180
-141
@@ -1,130 +1,173 @@
|
|||||||
package message
|
package message
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler holds dependencies for message CRUD operations.
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
hub *gateway.Hub
|
hub *gateway.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new message Handler.
|
|
||||||
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
||||||
return &Handler{db: db, hub: hub}
|
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) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/", h.List)
|
r.Get("/{channelID}/messages", h.List)
|
||||||
r.Post("/", h.Create)
|
r.Post("/{channelID}/messages", h.Create)
|
||||||
r.Patch("/{messageID}", h.Update)
|
r.Patch("/{messageID}", h.Update)
|
||||||
r.Delete("/{messageID}", h.Delete)
|
r.Delete("/{messageID}", h.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message is the JSON representation of a message.
|
type messageResponse struct {
|
||||||
type Message struct {
|
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ChannelID string `json:"channel_id"`
|
ChannelID string `json:"channel_id"`
|
||||||
AuthorID string `json:"author_id"`
|
AuthorID string `json:"author_id"`
|
||||||
|
AuthorName string `json:"author_username"`
|
||||||
|
DisplayName *string `json:"author_display_name"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
EditedAt *string `json:"edited_at,omitempty"`
|
EditedAt *string `json:"edited_at"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type createMessageRequest struct {
|
// isMember checks whether the given user is a member of the given server.
|
||||||
Content string `json:"content"`
|
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 {
|
// serverIDForChannel returns the server_id that owns the given channel.
|
||||||
Content string `json:"content"`
|
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.
|
// requireChannelAccess verifies the user is a member of the server owning the channel,
|
||||||
// Query params: limit (default 50, max 100), before (message ID cursor).
|
// returning the server_id if successful.
|
||||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
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")
|
channelID := chi.URLParam(r, "channelID")
|
||||||
if _, err := uuid.Parse(channelID); err != nil {
|
if _, ok := h.requireChannelAccess(w, r, channelID); !ok {
|
||||||
writeError(w, http.StatusBadRequest, "invalid channel id")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the user is a member of the server that owns this channel
|
// Parse query params
|
||||||
if !h.isChannelMember(r, userID, channelID) {
|
|
||||||
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse pagination params
|
|
||||||
limit := 50
|
limit := 50
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||||
limit = parsed
|
limit = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
before := r.URL.Query().Get("before")
|
before := r.URL.Query().Get("before")
|
||||||
|
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
if before != "" {
|
if before != "" {
|
||||||
if _, err := uuid.Parse(before); err != nil {
|
// Fetch the created_at of the cursor message
|
||||||
writeError(w, http.StatusBadRequest, "invalid before cursor")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
SELECT id, channel_id, author_id, content, edited_at, created_at
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name,
|
||||||
FROM messages
|
m.content, m.edited_at::text, m.created_at::text
|
||||||
WHERE channel_id = $1
|
FROM messages m
|
||||||
AND created_at < (SELECT created_at FROM messages WHERE id = $2)
|
INNER JOIN users u ON u.id = m.author_id
|
||||||
ORDER BY created_at DESC
|
WHERE m.channel_id = $1 AND m.created_at < $2::timestamptz
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
`, channelID, before, limit)
|
`, channelID, cursorTime.String, limit)
|
||||||
} else {
|
} else {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
SELECT id, channel_id, author_id, content, edited_at, created_at
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name,
|
||||||
FROM messages
|
m.content, m.edited_at::text, m.created_at::text
|
||||||
WHERE channel_id = $1
|
FROM messages m
|
||||||
ORDER BY created_at DESC
|
INNER JOIN users u ON u.id = m.author_id
|
||||||
|
WHERE m.channel_id = $1
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`, channelID, limit)
|
`, channelID, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to list messages")
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
messages := []Message{}
|
messages := make([]messageResponse, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m Message
|
var msg messageResponse
|
||||||
if err := rows.Scan(&m.ID, &m.ChannelID, &m.AuthorID, &m.Content, &m.EditedAt, &m.CreatedAt); err != nil {
|
var editedAt, createdAt sql.NullString
|
||||||
writeError(w, http.StatusInternalServerError, "server error")
|
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
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "server error")
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,49 +175,57 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(messages)
|
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) {
|
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")
|
channelID := chi.URLParam(r, "channelID")
|
||||||
if _, err := uuid.Parse(channelID); err != nil {
|
userID, ok := h.requireChannelAccess(w, r, channelID)
|
||||||
writeError(w, http.StatusBadRequest, "invalid channel id")
|
if !ok {
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !h.isChannelMember(r, userID, channelID) {
|
|
||||||
writeError(w, http.StatusForbidden, "not a member of this server")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req createMessageRequest
|
var req createMessageRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.Content == "" {
|
if req.Content == "" {
|
||||||
writeError(w, http.StatusBadRequest, "content is required")
|
http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg Message
|
var msg messageResponse
|
||||||
|
var editedAt sql.NullString
|
||||||
|
var createdAt sql.NullString
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO messages (channel_id, author_id, content)
|
INSERT INTO messages (channel_id, author_id, content)
|
||||||
VALUES ($1, $2, $3)
|
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(
|
`, 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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create message")
|
http.Error(w, `{"error":"failed to create message"}`, http.StatusInternalServerError)
|
||||||
return
|
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{
|
h.hub.BroadcastEvent(gateway.Event{
|
||||||
Type: gateway.EventMessageCreate,
|
Type: gateway.EventMessageCreate,
|
||||||
Data: msg,
|
Data: msg,
|
||||||
@@ -185,60 +236,78 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(msg)
|
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) {
|
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
channelID := chi.URLParam(r, "channelID")
|
|
||||||
messageID := chi.URLParam(r, "messageID")
|
messageID := chi.URLParam(r, "messageID")
|
||||||
if _, err := uuid.Parse(messageID); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid message id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req updateMessageRequest
|
var req updateMessageRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if req.Content == "" {
|
if req.Content == "" {
|
||||||
writeError(w, http.StatusBadRequest, "content is required")
|
http.Error(w, `{"error":"content is required"}`, http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify authorship
|
// Verify authorship
|
||||||
var authorID string
|
var authorID string
|
||||||
err := h.db.QueryRowContext(r.Context(),
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
`SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`,
|
SELECT author_id FROM messages WHERE id = $1
|
||||||
messageID, channelID,
|
`, messageID).Scan(&authorID)
|
||||||
).Scan(&authorID)
|
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if authorID != userID {
|
if authorID != userID {
|
||||||
writeError(w, http.StatusForbidden, "you can only edit your own messages")
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg Message
|
var msg messageResponse
|
||||||
|
var editedAt sql.NullString
|
||||||
|
var createdAt sql.NullString
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE messages SET content = $1, edited_at = NOW()
|
UPDATE messages
|
||||||
WHERE id = $2 AND channel_id = $3
|
SET content = $1, edited_at = NOW()
|
||||||
RETURNING id, channel_id, author_id, content, edited_at, created_at
|
WHERE id = $2
|
||||||
`, req.Content, messageID, channelID).Scan(
|
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
||||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt,
|
`, req.Content, messageID).Scan(
|
||||||
|
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update message")
|
http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError)
|
||||||
return
|
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{
|
h.hub.BroadcastEvent(gateway.Event{
|
||||||
Type: gateway.EventMessageUpdate,
|
Type: gateway.EventMessageUpdate,
|
||||||
Data: msg,
|
Data: msg,
|
||||||
@@ -248,52 +317,42 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(msg)
|
json.NewEncoder(w).Encode(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete handles DELETE /{messageID} — deletes a message (author only).
|
|
||||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
channelID := chi.URLParam(r, "channelID")
|
|
||||||
messageID := chi.URLParam(r, "messageID")
|
messageID := chi.URLParam(r, "messageID")
|
||||||
if _, err := uuid.Parse(messageID); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid message id")
|
// Verify authorship and get channel_id for the broadcast
|
||||||
|
var authorID, channelID string
|
||||||
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
|
SELECT author_id, channel_id FROM messages WHERE id = $1
|
||||||
|
`, messageID).Scan(&authorID, &channelID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
// 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)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "message not found")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if authorID != userID {
|
if authorID != userID {
|
||||||
writeError(w, http.StatusForbidden, "you can only delete your own messages")
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.db.ExecContext(r.Context(),
|
_, err = h.db.ExecContext(r.Context(), `
|
||||||
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
|
DELETE FROM messages WHERE id = $1
|
||||||
messageID, channelID,
|
`, messageID)
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to delete message")
|
http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, _ := result.RowsAffected()
|
// Broadcast MESSAGE_DELETE event via WebSocket
|
||||||
if rows == 0 {
|
|
||||||
writeError(w, http.StatusNotFound, "message not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast MESSAGE_DELETE event
|
|
||||||
h.hub.BroadcastEvent(gateway.Event{
|
h.hub.BroadcastEvent(gateway.Event{
|
||||||
Type: gateway.EventMessageDelete,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
@@ -304,23 +363,3 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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)
|
||||||
|
}
|
||||||
+20
-1
@@ -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 { LoginForm } from './components/LoginForm.tsx';
|
||||||
import { Layout } from './components/Layout.tsx';
|
import { Layout } from './components/Layout.tsx';
|
||||||
import { ChatArea } from './components/ChatArea.tsx';
|
import { ChatArea } from './components/ChatArea.tsx';
|
||||||
|
import { UserSettings } from './components/UserSettings.tsx';
|
||||||
import { useAuthStore } from './stores/auth.ts';
|
import { useAuthStore } from './stores/auth.ts';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -14,6 +15,24 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginForm />} />
|
<Route path="/login" element={<LoginForm />} />
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
|
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||||
|
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
|
← [BACK]
|
||||||
|
</Link>
|
||||||
|
<span className="text-gb-orange font-mono text-sm">SETTINGS</span>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<UserSettings />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useMessageStore } from '../stores/message.ts';
|
import { useMessageStore } from '../stores/message.ts';
|
||||||
import { useChannelStore } from '../stores/channel.ts';
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
|
import { GiphyPicker, type Gif } from './GiphyPicker';
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
@@ -18,6 +19,7 @@ export function ChatArea() {
|
|||||||
const fetchMessages = useMessageStore((state) => state.fetchMessages);
|
const fetchMessages = useMessageStore((state) => state.fetchMessages);
|
||||||
const sendMessage = useMessageStore((state) => state.sendMessage);
|
const sendMessage = useMessageStore((state) => state.sendMessage);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,6 +41,13 @@ export function ChatArea() {
|
|||||||
setInput('');
|
setInput('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGifSelect = async (gif: Gif) => {
|
||||||
|
if (!activeChannelId) return;
|
||||||
|
const content = ``;
|
||||||
|
await sendMessage(activeChannelId, content);
|
||||||
|
setShowGifPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-gb-bg">
|
<div className="flex flex-col h-full bg-gb-bg">
|
||||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s">
|
||||||
@@ -58,6 +67,14 @@ export function ChatArea() {
|
|||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
|
{showGifPicker && (
|
||||||
|
<div className="px-3 pb-1">
|
||||||
|
<GiphyPicker
|
||||||
|
onSelect={handleGifSelect}
|
||||||
|
onClose={() => setShowGifPicker(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
|
<form onSubmit={handleSubmit} className="p-3 flex items-center gap-2">
|
||||||
<span className="text-gb-fg-f select-none">{'>'}</span>
|
<span className="text-gb-fg-f select-none">{'>'}</span>
|
||||||
<input
|
<input
|
||||||
@@ -68,6 +85,15 @@ export function ChatArea() {
|
|||||||
className="terminal-input"
|
className="terminal-input"
|
||||||
disabled={!activeChannelId}
|
disabled={!activeChannelId}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowGifPicker((prev) => !prev)}
|
||||||
|
disabled={!activeChannelId}
|
||||||
|
className="text-gb-fg-f hover:text-gb-orange font-mono text-sm select-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Toggle GIF picker"
|
||||||
|
>
|
||||||
|
[GIF]
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<Gif[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
// Fetch trending on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTrending = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const gifs = await api.get<Gif[]>('/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<Gif[]>('/gifs/trending');
|
||||||
|
setResults(gifs);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTrending();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const gifs = await api.get<Gif[]>(
|
||||||
|
`/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 (
|
||||||
|
<div className="border border-gb-bg-t bg-gb-bg rounded font-mono text-sm flex flex-col max-h-80 w-full">
|
||||||
|
{/* Header with search */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-gb-bg-t">
|
||||||
|
<span className="text-gb-fg-f select-none">{'>'} search:</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="_"
|
||||||
|
className="bg-transparent text-gb-fg outline-none flex-1 caret-gb-orange"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gb-fg-f hover:text-gb-orange select-none ml-1"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
[x]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results grid */}
|
||||||
|
<div className="overflow-y-auto p-2">
|
||||||
|
{loading && (
|
||||||
|
<p className="text-gb-fg-f text-center py-2">[loading...]</p>
|
||||||
|
)}
|
||||||
|
{!loading && results.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f text-center py-2">[no results]</p>
|
||||||
|
)}
|
||||||
|
{!loading && results.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{results.map((gif) => (
|
||||||
|
<button
|
||||||
|
key={gif.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleClick(gif)}
|
||||||
|
className="cursor-pointer border border-transparent hover:border-gb-orange rounded overflow-hidden focus:outline-none focus:border-gb-orange"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={gif.images.fixed_height.url}
|
||||||
|
alt={gif.title || 'GIF'}
|
||||||
|
className="w-full h-auto object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Gif };
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Box-drawn frame */}
|
||||||
|
<div className="border border-gb-bg-t p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<pre className="text-gb-orange font-mono text-center mb-6">
|
||||||
|
{'┌──────────────────────────────────┐\n'}
|
||||||
|
{'│ === PROFILE SETTINGS === │\n'}
|
||||||
|
{'└──────────────────────────────────┘'}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-5">
|
||||||
|
{/* Avatar Section */}
|
||||||
|
<div className="border border-gb-bg-t p-4">
|
||||||
|
<label className="block text-gb-fg-f mb-2 font-mono text-sm">
|
||||||
|
AVATAR:
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="w-16 h-16 rounded border-2 flex items-center justify-center overflow-hidden shrink-0"
|
||||||
|
style={{ borderColor: hexValid ? accentColor : '#504945' }}
|
||||||
|
>
|
||||||
|
{user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt="avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gb-fg-f text-2xl font-mono">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-gb-fg-s text-sm font-mono truncate max-w-xs">
|
||||||
|
{user.avatar || 'no avatar set'}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? '[UPLOADING...]' : '[UPLOAD NEW]'}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{uploadError && (
|
||||||
|
<p className="text-gb-red text-xs font-mono">ERR: {uploadError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
||||||
|
DISPLAY_NAME:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value.slice(0, 32))}
|
||||||
|
maxLength={32}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
placeholder="your display name"
|
||||||
|
/>
|
||||||
|
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
|
||||||
|
{displayName.length}/32
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
||||||
|
BIO:
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value.slice(0, 250))}
|
||||||
|
maxLength={250}
|
||||||
|
rows={4}
|
||||||
|
className="terminal-input w-full resize-none"
|
||||||
|
placeholder="tell the world about yourself..."
|
||||||
|
/>
|
||||||
|
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
|
||||||
|
{bio.length}/250
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Text */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
||||||
|
STATUS_TEXT:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={statusText}
|
||||||
|
onChange={(e) => setStatusText(e.target.value.slice(0, 128))}
|
||||||
|
maxLength={128}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
placeholder="what are you up to?"
|
||||||
|
/>
|
||||||
|
<span className="text-gb-fg-f text-xs font-mono mt-1 block">
|
||||||
|
{statusText.length}/128
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-sm">
|
||||||
|
ACCENT_COLOR:
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={accentColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
let val = e.target.value;
|
||||||
|
if (!val.startsWith('#')) val = '#' + val;
|
||||||
|
setAccentColor(val.slice(0, 7));
|
||||||
|
}}
|
||||||
|
maxLength={7}
|
||||||
|
className="terminal-input w-32"
|
||||||
|
placeholder="#fe8019"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 border border-gb-bg-t rounded-sm shrink-0"
|
||||||
|
style={{ backgroundColor: hexValid ? accentColor : '#504945' }}
|
||||||
|
title={hexValid ? accentColor : 'invalid hex'}
|
||||||
|
/>
|
||||||
|
{!hexValid && accentColor.length > 0 && (
|
||||||
|
<span className="text-gb-red text-xs font-mono">
|
||||||
|
invalid hex
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors & Success */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-gb-red text-sm font-mono">ERR: {error}</p>
|
||||||
|
)}
|
||||||
|
{saved && (
|
||||||
|
<p className="text-gb-green text-sm font-mono animate-pulse">
|
||||||
|
[saved!]
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="terminal-button"
|
||||||
|
disabled={isLoading || !hexValid}
|
||||||
|
>
|
||||||
|
{isLoading ? '[SAVING...]' : '[SAVE]'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||||
|
>
|
||||||
|
[CANCEL]
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer info */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||||
|
<p className="text-gb-fg-f text-xs font-mono">
|
||||||
|
USER: {user.username} | ID: {user.id.slice(0, 8)}... | JOINED: {new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-5
@@ -6,10 +6,21 @@ export type UserStatus = 'online' | 'idle' | 'dnd' | 'offline';
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
display_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar: string | null;
|
avatar: string;
|
||||||
|
bio: string;
|
||||||
|
accent_color: string;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
|
status_text: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfilePayload {
|
||||||
|
display_name?: string;
|
||||||
|
bio?: string;
|
||||||
|
accent_color?: string;
|
||||||
|
status_text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -18,9 +29,10 @@ interface AuthState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (email: string, username: string, password: string) => Promise<void>;
|
register: (email: string, username: string, password: string, displayName?: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
fetchMe: () => Promise<void>;
|
fetchMe: () => Promise<void>;
|
||||||
|
updateProfile: (data: UpdateProfilePayload) => Promise<void>;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +57,10 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
register: async (email, username, password) => {
|
register: async (email, username, password, displayName) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/register', { email, username, password });
|
await api.post('/auth/register', { email, username, password, display_name: displayName || username });
|
||||||
const user = await api.get<User>('/auth/me');
|
const user = await api.get<User>('/auth/me');
|
||||||
set({ user, isAuthenticated: true, isLoading: false });
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -89,5 +101,19 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateProfile: async (data) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const user = await api.patch<User>('/auth/me', data);
|
||||||
|
set({ user, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Update failed',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ChannelList.tsx","./src/components/ChatArea.tsx","./src/components/GiphyPicker.tsx","./src/components/Layout.tsx","./src/components/LoginForm.tsx","./src/components/MemberList.tsx","./src/components/ServerBar.tsx","./src/components/UserSettings.tsx","./src/lib/api.ts","./src/stores/auth.ts","./src/stores/channel.ts","./src/stores/message.ts","./src/stores/server.ts","./src/stores/ws.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user