diff --git a/.gitignore b/.gitignore index 023c891..731c65e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ postgres_data/ valkey_data/ minio_data/ server +migrate +dumpster-server diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..576316a --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +.PHONY: build build-web build-server run dev docker-up docker-down clean + +# Build everything +build: build-web build-server + +# Build the Go server +build-server: + CGO_ENABLED=0 go build -o dumpster-server ./cmd/server + +# Build the web frontend +build-web: + cd web && npm run build + +# Run the server locally (dev mode) +run: build-server + ./dumpster-server + +# Run web dev server (in a separate terminal) +dev-web: + cd web && npm run dev + +# Start all backing services (no app, run that locally) +docker-dev: + docker compose -f docker/compose.yml up postgres valkey minio livekit coturn -d + +# Start everything in Docker +docker-up: + docker compose -f docker/compose.yml up -d --build + +# Stop everything +docker-down: + docker compose -f docker/compose.yml down + +# Stop and remove volumes +docker-clean: + docker compose -f docker/compose.yml down -v + +# Clean build artifacts +clean: + rm -f dumpster-server + rm -rf web/dist + rm -rf web/node_modules diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go new file mode 100644 index 0000000..7bfc74c --- /dev/null +++ b/cmd/migrate/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "database/sql" + "flag" + "fmt" + "log" + "os" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" + _ "github.com/jackc/pgx/v5/stdlib" +) + +func main() { + direction := flag.String("direction", "up", "migration direction: up or down") + flag.Parse() + + cfg := config.Load() + + db, err := sql.Open("pgx", cfg.DatabaseDSN()) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatalf("failed to ping database: %v", err) + } + + switch *direction { + case "up": + fmt.Println("running migrations up...") + if err := runUp(db); err != nil { + log.Fatalf("migration failed: %v", err) + } + fmt.Println("migrations complete") + case "down": + fmt.Println("running migrations down...") + if err := runDown(db); err != nil { + log.Fatalf("migration failed: %v", err) + } + fmt.Println("migrations complete") + default: + fmt.Fprintf(os.Stderr, "unknown direction: %s\n", *direction) + os.Exit(1) + } +} + +const upSQL = ` +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(32) NOT NULL UNIQUE, + display_name VARCHAR(32), + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255), + avatar TEXT, + status VARCHAR(16) NOT NULL DEFAULT 'offline', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS servers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + icon TEXT, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + type VARCHAR(16) NOT NULL DEFAULT 'text', + category VARCHAR(100) NOT NULL DEFAULT 'general', + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (server_id, name) +); + +CREATE TABLE IF NOT EXISTS members ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, server_id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + edited_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + color VARCHAR(7), + permissions BIGINT NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS member_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id); +` + +const downSQL = ` +DROP TABLE IF EXISTS member_roles; +DROP TABLE IF EXISTS roles; +DROP TABLE IF EXISTS messages; +DROP TABLE IF EXISTS members; +DROP TABLE IF EXISTS channels; +DROP TABLE IF EXISTS servers; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS users; +` + +func runUp(db *sql.DB) error { + _, err := db.Exec(upSQL) + return err +} + +func runDown(db *sql.DB) error { + _, err := db.Exec(downSQL) + return err +} diff --git a/cmd/server/main.go b/cmd/server/main.go index e09bab9..b04d022 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,10 +7,15 @@ import ( "os" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/auth" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/channel" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/config" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/db" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/message" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/server" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" + chimw "github.com/go-chi/chi/v5/middleware" ) func main() { @@ -30,20 +35,73 @@ func main() { os.Exit(1) } - r := chi.NewRouter() - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(middleware.RequestID) + // Session store for middleware + sessionStore := auth.NewSessionStore(database.DB, cfg) + // WebSocket hub + hub := gateway.NewHub(logger) + go hub.Run() + + r := chi.NewRouter() + r.Use(chimw.Logger) + r.Use(chimw.Recoverer) + r.Use(chimw.RequestID) + + // Health check r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) - r.Route("/api/v1/auth", func(r chi.Router) { - auth.NewHandler(database.DB, cfg).RegisterRoutes(r) + // WebSocket endpoint + r.Get("/ws", func(w http.ResponseWriter, r *http.Request) { + gateway.ServeWS(database.DB, hub, logger, w, r) }) + // API routes + r.Route("/api/v1", func(r chi.Router) { + // Auth (public) + r.Route("/auth", func(r chi.Router) { + auth.NewHandler(database.DB, cfg).RegisterRoutes(r) + }) + + // Protected routes + r.Group(func(r chi.Router) { + r.Use(middleware.Session(sessionStore, cfg)) + r.Use(middleware.RequireAuth) + + // Servers + r.Route("/servers", func(r chi.Router) { + server.NewHandler(database.DB).RegisterRoutes(r) + + // Channels (nested under servers) + r.Route("/{serverID}/channels", func(r chi.Router) { + channel.NewHandler(database.DB).RegisterRoutes(r) + }) + }) + + // Messages (under channels) + r.Route("/channels/{channelID}/messages", func(r chi.Router) { + message.NewHandler(database.DB, hub).RegisterRoutes(r) + }) + }) + }) + + // Static file serving for production (SPA) + staticDir := "/srv/web" + if _, err := os.Stat(staticDir); err == nil { + fileServer := http.FileServer(http.Dir(staticDir)) + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + // If the file exists, serve it; otherwise serve index.html (SPA fallback) + path := staticDir + r.URL.Path + if _, err := os.Stat(path); os.IsNotExist(err) { + http.ServeFile(w, r, staticDir+"/index.html") + return + } + fileServer.ServeHTTP(w, r) + }) + } + addr := fmt.Sprintf(":%s", cfg.Port) logger.Info("starting server", "addr", addr) if err := http.ListenAndServe(addr, r); err != nil { diff --git a/docker/Caddyfile b/docker/Caddyfile index c4da009..db0432a 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -3,18 +3,25 @@ } :80 { + # API routes handle_path /api/* { reverse_proxy app:8080 } - handle_path /ws { - reverse_proxy app:8080 + # WebSocket gateway + handle /ws { + reverse_proxy app:8080 { + header_up Connection {>Connection} + header_up Upgrade {>Upgrade} + } } + # LiveKit (if proxied) handle_path /livekit/* { reverse_proxy livekit:7880 } + # SPA fallback - serve index.html for all other routes handle { root * /srv/web try_files {path} /index.html diff --git a/docker/Dockerfile b/docker/Dockerfile index 2ad36f6..ff3aab5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,13 +2,14 @@ FROM golang:1.24-alpine AS go-builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download -COPY . . +COPY cmd/ cmd/ +COPY internal/ internal/ RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/dumpster-server ./cmd/server FROM node:22-alpine AS web-builder WORKDIR /app COPY web/package.json web/package-lock.json ./ -RUN npm ci +RUN npm ci --include=dev COPY web/ . RUN npm run build diff --git a/docker/compose.yml b/docker/compose.yml index 04bc4a9..8c16554 100644 --- a/docker/compose.yml +++ b/docker/compose.yml @@ -1,6 +1,26 @@ version: '3.8' services: + app: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: dumpster-app + env_file: + - ../.env + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + VALKEY_URL: redis://valkey:6379 + MINIO_ENDPOINT: minio:9000 + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + postgres: image: postgres:16 container_name: dumpster-postgres @@ -13,7 +33,7 @@ services: ports: - "5432:5432" healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dumpster} -d ${POSTGRES_DB:-dumpster}"] interval: 5s timeout: 5s retries: 5 @@ -47,8 +67,7 @@ services: livekit: image: livekit/livekit-server:latest container_name: dumpster-livekit - command: > - --config /etc/livekit.yaml + command: --config /etc/livekit.yaml ports: - "7880:7880" - "7881:7881" @@ -57,19 +76,18 @@ services: volumes: - ./livekit.yaml:/etc/livekit.yaml:ro environment: - LIVEKIT_KEYS: "${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET}" - depends_on: - - coturn + LIVEKIT_KEYS: "${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" coturn: image: coturn/coturn:latest container_name: dumpster-coturn - command: > - -n --log-file=stdout - --external-ip=$$(detect-external-ip) - --min-port=10000 --max-port=20000 - --realm=${DUMPSTER_HOST:-localhost} - --user=${LIVEKIT_API_KEY}:${LIVEKIT_API_SECRET} + command: + - "-n" + - "--log-file=stdout" + - "--min-port=10000" + - "--max-port=20000" + - "--realm=${DUMPSTER_HOST:-localhost}" + - "--user=${LIVEKIT_API_KEY:-devkey}:${LIVEKIT_API_SECRET:-secret}" ports: - "3478:3478/tcp" - "3478:3478/udp" diff --git a/go.mod b/go.mod index 82a1a88..e63af94 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.4 require ( github.com/go-chi/chi/v5 v5.3.0 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 golang.org/x/crypto v0.53.0 ) diff --git a/go.sum b/go.sum index 7b54612..3eadd49 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/channel/handlers.go b/internal/channel/handlers.go new file mode 100644 index 0000000..3d65329 --- /dev/null +++ b/internal/channel/handlers.go @@ -0,0 +1,318 @@ +package channel + +import ( + "database/sql" + "encoding/json" + "net/http" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// Handler holds the database dependency for channel CRUD operations. +type Handler struct { + db *sql.DB +} + +// NewHandler creates a new channel Handler. +func NewHandler(db *sql.DB) *Handler { + return &Handler{db: db} +} + +// RegisterRoutes registers channel routes on the given chi.Router. +// Expects to be mounted under /servers/{serverID}/channels. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Post("/", h.Create) + r.Get("/", h.List) + r.Get("/{channelID}", h.Get) + r.Patch("/{channelID}", h.Update) + r.Delete("/{channelID}", h.Delete) +} + +// Channel is the JSON representation of a channel. +type Channel struct { + ID string `json:"id"` + ServerID string `json:"server_id"` + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Position int `json:"position"` + CreatedAt string `json:"created_at"` +} + +type createChannelRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Category string `json:"category"` + Position *int `json:"position,omitempty"` +} + +type updateChannelRequest struct { + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Category *string `json:"category,omitempty"` + Position *int `json:"position,omitempty"` +} + +// Create handles POST / — creates a new channel in a server. +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + serverID := chi.URLParam(r, "serverID") + if _, err := uuid.Parse(serverID); err != nil { + writeError(w, http.StatusBadRequest, "invalid server id") + return + } + + // Verify membership + if !h.isMember(r, userID, serverID) { + writeError(w, http.StatusForbidden, "not a member of this server") + return + } + + var req createChannelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + 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" + } + + position := 0 + if req.Position != nil { + position = *req.Position + } + + var channelID string + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO channels (server_id, name, type, category, position) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, serverID, req.Name, req.Type, req.Category, position).Scan(&channelID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create channel") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"id": channelID}) +} + +// List handles GET / — returns all channels in a server. +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + serverID := chi.URLParam(r, "serverID") + if _, err := uuid.Parse(serverID); err != nil { + writeError(w, http.StatusBadRequest, "invalid server id") + return + } + + if !h.isMember(r, userID, serverID) { + writeError(w, http.StatusForbidden, "not a member of this server") + return + } + + rows, err := h.db.QueryContext(r.Context(), ` + SELECT id, server_id, name, type, category, position, created_at + FROM channels + WHERE server_id = $1 + ORDER BY position ASC, created_at ASC + `, serverID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list channels") + return + } + defer rows.Close() + + channels := []Channel{} + for rows.Next() { + var c Channel + if err := rows.Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, "server error") + return + } + channels = append(channels, c) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "server error") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(channels) +} + +// Get handles GET /{channelID} — returns a single channel. +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + serverID := chi.URLParam(r, "serverID") + channelID := chi.URLParam(r, "channelID") + if _, err := uuid.Parse(channelID); err != nil { + writeError(w, http.StatusBadRequest, "invalid channel id") + return + } + + if !h.isMember(r, userID, serverID) { + writeError(w, http.StatusForbidden, "not a member of this server") + return + } + + var c Channel + err := h.db.QueryRowContext(r.Context(), ` + SELECT id, server_id, name, type, category, position, created_at + FROM channels + WHERE id = $1 AND server_id = $2 + `, channelID, serverID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt) + if err != nil { + writeError(w, http.StatusNotFound, "channel not found") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(c) +} + +// Update handles PATCH /{channelID} — updates a channel's properties. +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + serverID := chi.URLParam(r, "serverID") + channelID := chi.URLParam(r, "channelID") + if _, err := uuid.Parse(channelID); err != nil { + writeError(w, http.StatusBadRequest, "invalid channel id") + return + } + + if !h.isMember(r, userID, serverID) { + writeError(w, http.StatusForbidden, "not a member of this server") + return + } + + var req updateChannelRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + // Validate type if provided + if req.Type != nil && *req.Type != "text" && *req.Type != "voice" { + writeError(w, http.StatusBadRequest, "type must be text or voice") + return + } + + result, err := h.db.ExecContext(r.Context(), ` + UPDATE channels SET + name = COALESCE($1, name), + type = COALESCE($2, type), + category = COALESCE($3, category), + position = COALESCE($4, position) + WHERE id = $5 AND server_id = $6 + `, req.Name, req.Type, req.Category, req.Position, channelID, serverID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update channel") + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + writeError(w, http.StatusNotFound, "channel not found") + return + } + + var c Channel + h.db.QueryRowContext(r.Context(), ` + SELECT id, server_id, name, type, category, position, created_at + FROM channels WHERE id = $1 + `, channelID).Scan(&c.ID, &c.ServerID, &c.Name, &c.Type, &c.Category, &c.Position, &c.CreatedAt) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(c) +} + +// Delete handles DELETE /{channelID} — deletes a channel. +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + serverID := chi.URLParam(r, "serverID") + channelID := chi.URLParam(r, "channelID") + if _, err := uuid.Parse(channelID); err != nil { + writeError(w, http.StatusBadRequest, "invalid channel id") + return + } + + if !h.isMember(r, userID, serverID) { + writeError(w, http.StatusForbidden, "not a member of this server") + return + } + + result, err := h.db.ExecContext(r.Context(), + `DELETE FROM channels WHERE id = $1 AND server_id = $2`, + channelID, serverID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete channel") + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + writeError(w, http.StatusNotFound, "channel not found") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// isMember checks if a user is a member of the given server. +func (h *Handler) isMember(r *http.Request, userID, serverID string) bool { + var exists bool + h.db.QueryRowContext(r.Context(), + `SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)`, + userID, serverID, + ).Scan(&exists) + return exists +} + +// writeError sends a JSON error response. +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/internal/db/db.go b/internal/db/db.go index 25f6311..8b70f97 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -87,6 +87,24 @@ CREATE TABLE IF NOT EXISTS messages ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + color VARCHAR(7), + permissions BIGINT NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS member_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + CREATE INDEX IF NOT EXISTS idx_messages_channel_created ON messages(channel_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE INDEX IF NOT EXISTS idx_members_server_user ON members(server_id, user_id); ` diff --git a/internal/gateway/client.go b/internal/gateway/client.go new file mode 100644 index 0000000..ec19990 --- /dev/null +++ b/internal/gateway/client.go @@ -0,0 +1,154 @@ +package gateway + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 4096 +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// Client is a middleman between the websocket connection and the hub. +type Client struct { + Hub *Hub + Conn *websocket.Conn + UserID string + send chan []byte +} + +// readPump pumps messages from the websocket connection to the hub. +func (c *Client) readPump() { + defer func() { + c.Hub.Unregister(c) + c.Conn.Close() + }() + + c.Conn.SetReadLimit(maxMessageSize) + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + c.Conn.SetPongHandler(func(string) error { + c.Conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, raw, err := c.Conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + c.Hub.logger.Warn("websocket read error", "error", err, "user_id", c.UserID) + } + break + } + + var event Event + if err := json.Unmarshal(raw, &event); err != nil { + c.Hub.logger.Warn("invalid event JSON", "error", err, "user_id", c.UserID) + continue + } + + // Handle client-sent events (e.g. TYPING_START, PRESENCE_UPDATE) + // For now, broadcast them to all clients + switch event.Type { + case EventTypingStart, EventPresenceUpdate: + c.Hub.BroadcastEvent(event) + default: + c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID) + } + } +} + +// writePump pumps messages from the hub to the websocket connection. +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.Conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + c.Conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := c.Conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + + // Drain queued messages into the current write + n := len(c.send) + for i := 0; i < n; i++ { + w.Write([]byte{'\n'}) + w.Write(<-c.send) + } + + if err := w.Close(); err != nil { + return + } + + case <-ticker.C: + c.Conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// ServeWS handles websocket requests from the peer. It authenticates the +// client via a session token passed as the "token" query parameter. +func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, `{"error":"missing token"}`, http.StatusUnauthorized) + return + } + + var userID string + err := db.QueryRowContext(context.Background(), + `SELECT user_id FROM sessions WHERE token = $1 AND expires_at > NOW()`, + token, + ).Scan(&userID) + if err != nil { + http.Error(w, `{"error":"invalid or expired session"}`, http.StatusUnauthorized) + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error("websocket upgrade failed", "error", err) + return + } + + client := &Client{ + Hub: hub, + Conn: conn, + UserID: userID, + send: make(chan []byte, 256), + } + + hub.Register(client) + + go client.writePump() + go client.readPump() +} diff --git a/internal/gateway/events.go b/internal/gateway/events.go new file mode 100644 index 0000000..dc1bfd3 --- /dev/null +++ b/internal/gateway/events.go @@ -0,0 +1,50 @@ +package gateway + +import "encoding/json" + +// Event type constants. +const ( + EventMessageCreate = "MESSAGE_CREATE" + EventMessageUpdate = "MESSAGE_UPDATE" + EventMessageDelete = "MESSAGE_DELETE" + EventChannelCreate = "CHANNEL_CREATE" + EventChannelUpdate = "CHANNEL_UPDATE" + EventChannelDelete = "CHANNEL_DELETE" + EventMemberAdd = "SERVER_MEMBER_ADD" + EventMemberRemove = "SERVER_MEMBER_REMOVE" + EventPresenceUpdate = "PRESENCE_UPDATE" + EventTypingStart = "TYPING_START" +) + +// Event represents a WebSocket event sent to clients. +type Event struct { + Type string `json:"type"` + Data interface{} `json:"data,omitempty"` +} + +// MarshalJSON implements custom JSON marshaling for Event. +func (e Event) MarshalJSON() ([]byte, error) { + type eventAlias struct { + Type string `json:"type"` + Data interface{} `json:"data,omitempty"` + } + return json.Marshal(eventAlias{ + Type: e.Type, + Data: e.Data, + }) +} + +// UnmarshalJSON implements custom JSON unmarshaling for Event. +func (e *Event) UnmarshalJSON(data []byte) error { + type eventAlias struct { + Type string `json:"type"` + Data json.RawMessage `json:"data,omitempty"` + } + var raw eventAlias + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + e.Type = raw.Type + e.Data = raw.Data + return nil +} diff --git a/internal/gateway/hub.go b/internal/gateway/hub.go new file mode 100644 index 0000000..50651e0 --- /dev/null +++ b/internal/gateway/hub.go @@ -0,0 +1,90 @@ +package gateway + +import ( + "encoding/json" + "log/slog" + "sync" +) + +// Hub maintains the set of active clients and broadcasts messages to them. +type Hub struct { + mu sync.RWMutex + clients map[*Client]struct{} + register chan *Client + unregister chan *Client + broadcast chan []byte + logger *slog.Logger +} + +// NewHub creates a new Hub. +func NewHub(logger *slog.Logger) *Hub { + return &Hub{ + clients: make(map[*Client]struct{}), + register: make(chan *Client), + unregister: make(chan *Client), + broadcast: make(chan []byte, 256), + logger: logger, + } +} + +// Run starts the hub's main event loop. Call this in a goroutine. +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + h.clients[client] = struct{}{} + h.mu.Unlock() + h.logger.Info("client connected", "user_id", client.UserID) + + case client := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + h.logger.Info("client disconnected", "user_id", client.UserID) + + case message := <-h.broadcast: + h.mu.RLock() + for client := range h.clients { + select { + case client.send <- message: + default: + go func(c *Client) { + h.unregister <- c + }(client) + } + } + h.mu.RUnlock() + } + } +} + +// Register queues a client for registration with the hub. +func (h *Hub) Register(client *Client) { + h.register <- client +} + +// Unregister queues a client for removal from the hub. +func (h *Hub) Unregister(client *Client) { + h.unregister <- client +} + +// BroadcastEvent serializes an Event and broadcasts it to all connected clients. +func (h *Hub) BroadcastEvent(event Event) { + data, err := json.Marshal(event) + if err != nil { + h.logger.Error("failed to marshal event", "type", event.Type, "error", err) + return + } + h.broadcast <- data +} + +// ClientCount returns the number of connected clients. +func (h *Hub) ClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} diff --git a/internal/message/handlers.go b/internal/message/handlers.go new file mode 100644 index 0000000..93ab9a5 --- /dev/null +++ b/internal/message/handlers.go @@ -0,0 +1,326 @@ +package message + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" + "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// Handler holds dependencies for message CRUD operations. +type Handler struct { + db *sql.DB + hub *gateway.Hub +} + +// NewHandler creates a new message Handler. +func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { + return &Handler{db: db, hub: hub} +} + +// RegisterRoutes registers message routes on the given chi.Router. +// Expects to be mounted under /channels/{channelID}/messages. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/", h.List) + r.Post("/", h.Create) + r.Patch("/{messageID}", h.Update) + r.Delete("/{messageID}", h.Delete) +} + +// Message is the JSON representation of a message. +type Message struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + AuthorID string `json:"author_id"` + Content string `json:"content"` + EditedAt *string `json:"edited_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +type createMessageRequest struct { + Content string `json:"content"` +} + +type updateMessageRequest struct { + Content string `json:"content"` +} + +// List handles GET / — returns messages in a channel with cursor-based pagination. +// Query params: limit (default 50, max 100), before (message ID cursor). +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + channelID := chi.URLParam(r, "channelID") + if _, err := uuid.Parse(channelID); err != nil { + writeError(w, http.StatusBadRequest, "invalid channel id") + return + } + + // Verify the user is a member of the server that owns this channel + if !h.isChannelMember(r, userID, channelID) { + writeError(w, http.StatusForbidden, "not a member of this server") + return + } + + // Parse pagination params + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed + } + } + + before := r.URL.Query().Get("before") + + var rows *sql.Rows + var err error + + if before != "" { + if _, err := uuid.Parse(before); err != nil { + writeError(w, http.StatusBadRequest, "invalid before cursor") + return + } + rows, err = h.db.QueryContext(r.Context(), ` + SELECT id, channel_id, author_id, content, edited_at, created_at + FROM messages + WHERE channel_id = $1 + AND created_at < (SELECT created_at FROM messages WHERE id = $2) + ORDER BY created_at DESC + LIMIT $3 + `, channelID, before, limit) + } else { + rows, err = h.db.QueryContext(r.Context(), ` + SELECT id, channel_id, author_id, content, edited_at, created_at + FROM messages + WHERE channel_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, channelID, limit) + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list messages") + return + } + defer rows.Close() + + messages := []Message{} + for rows.Next() { + var m Message + if err := rows.Scan(&m.ID, &m.ChannelID, &m.AuthorID, &m.Content, &m.EditedAt, &m.CreatedAt); err != nil { + writeError(w, http.StatusInternalServerError, "server error") + return + } + messages = append(messages, m) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "server error") + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) +} + +// Create handles POST / — creates a new message and broadcasts MESSAGE_CREATE. +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + channelID := chi.URLParam(r, "channelID") + if _, err := uuid.Parse(channelID); err != nil { + writeError(w, http.StatusBadRequest, "invalid channel id") + return + } + + if !h.isChannelMember(r, userID, channelID) { + writeError(w, http.StatusForbidden, "not a member of this server") + return + } + + var req createMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + if req.Content == "" { + writeError(w, http.StatusBadRequest, "content is required") + return + } + + var msg Message + err := h.db.QueryRowContext(r.Context(), ` + INSERT INTO messages (channel_id, author_id, content) + VALUES ($1, $2, $3) + RETURNING id, channel_id, author_id, content, edited_at, created_at + `, channelID, userID, req.Content).Scan( + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create message") + return + } + + // Broadcast MESSAGE_CREATE event via the gateway hub + h.hub.BroadcastEvent(gateway.Event{ + Type: gateway.EventMessageCreate, + Data: msg, + }) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(msg) +} + +// Update handles PATCH /{messageID} — updates a message (author only). +func (h *Handler) Update(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") + messageID := chi.URLParam(r, "messageID") + if _, err := uuid.Parse(messageID); err != nil { + writeError(w, http.StatusBadRequest, "invalid message id") + return + } + + var req updateMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + if req.Content == "" { + writeError(w, http.StatusBadRequest, "content is required") + return + } + + // Verify authorship + var authorID string + err := h.db.QueryRowContext(r.Context(), + `SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`, + messageID, channelID, + ).Scan(&authorID) + if err != nil { + writeError(w, http.StatusNotFound, "message not found") + return + } + if authorID != userID { + writeError(w, http.StatusForbidden, "you can only edit your own messages") + return + } + + var msg Message + err = h.db.QueryRowContext(r.Context(), ` + UPDATE messages SET content = $1, edited_at = NOW() + WHERE id = $2 AND channel_id = $3 + RETURNING id, channel_id, author_id, content, edited_at, created_at + `, req.Content, messageID, channelID).Scan( + &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &msg.EditedAt, &msg.CreatedAt, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update message") + return + } + + // Broadcast MESSAGE_UPDATE event + h.hub.BroadcastEvent(gateway.Event{ + Type: gateway.EventMessageUpdate, + Data: msg, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(msg) +} + +// Delete handles DELETE /{messageID} — deletes a message (author only). +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { + userID, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + + channelID := chi.URLParam(r, "channelID") + messageID := chi.URLParam(r, "messageID") + if _, err := uuid.Parse(messageID); err != nil { + writeError(w, http.StatusBadRequest, "invalid message id") + return + } + + // Verify authorship + var authorID string + err := h.db.QueryRowContext(r.Context(), + `SELECT author_id FROM messages WHERE id = $1 AND channel_id = $2`, + messageID, channelID, + ).Scan(&authorID) + if err != nil { + writeError(w, http.StatusNotFound, "message not found") + return + } + if authorID != userID { + writeError(w, http.StatusForbidden, "you can only delete your own messages") + return + } + + result, err := h.db.ExecContext(r.Context(), + `DELETE FROM messages WHERE id = $1 AND channel_id = $2`, + messageID, channelID, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete message") + return + } + + rows, _ := result.RowsAffected() + if rows == 0 { + writeError(w, http.StatusNotFound, "message not found") + return + } + + // Broadcast MESSAGE_DELETE event + h.hub.BroadcastEvent(gateway.Event{ + Type: gateway.EventMessageDelete, + Data: map[string]string{ + "id": messageID, + "channel_id": channelID, + }, + }) + + w.WriteHeader(http.StatusNoContent) +} + +// isChannelMember checks if a user is a member of the server that owns the channel. +func (h *Handler) isChannelMember(r *http.Request, userID, channelID string) bool { + var exists bool + h.db.QueryRowContext(r.Context(), ` + SELECT EXISTS( + SELECT 1 FROM members m + INNER JOIN channels c ON c.server_id = m.server_id + WHERE m.user_id = $1 AND c.id = $2 + ) + `, userID, channelID).Scan(&exists) + return exists +} + +// writeError sends a JSON error response. +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index f90339d..0000000 --- a/web/src/App.css +++ /dev/null @@ -1,184 +0,0 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} diff --git a/web/src/App.tsx b/web/src/App.tsx index 9ef397a..690a502 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,17 +1,33 @@ -function App() { - return ( -
- {"┌─ DUMPSTER ─┐"}
-
- The server is running. Frontend coming soon.
- -[no channels]
+ )} + {categories.map(([category, list]) => ( +[loading...]
} + {!isLoading && messages.length === 0 && ( +[no messages in this channel]
+ )} + {messages.map((message) => ( +{'┌─ DUMPSTER ─┐'}
+ + Use Link component: [HOME] +
+ )} +[no members]
+ )} + {online.length > 0 && ( +