package channel import ( "context" "database/sql" "encoding/json" "errors" "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions" "github.com/go-chi/chi/v5" ) type Handler struct { db *sql.DB checker *permissions.Checker } func NewHandler(db *sql.DB, checker *permissions.Checker) *Handler { return &Handler{db: db, checker: checker} } 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) r.Post("/{channelID}/threads", h.CreateThread) r.Get("/{channelID}/threads", h.ListThreads) r.Patch("/threads/{threadID}", h.UpdateThread) h.registerForumRoutes(r) h.registerCalendarRoutes(r) h.registerDocRoutes(r) h.registerListRoutes(r) h.registerOverrideRoutes(r) } type createChannelRequest struct { Name string `json:"name"` Type string `json:"type"` Category string `json:"category"` Position *int `json:"position"` GroupID *string `json:"group_id"` } type channelResponse 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"` SlowmodeSeconds int `json:"slowmode_seconds"` GroupID *string `json:"group_id"` CreatedAt string `json:"created_at"` } // isMember checks whether the given user is a member of the given server. func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, error) { var exists bool err := h.db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, userID, serverID).Scan(&exists) return exists, err } // serverIDForChannel returns the server_id that owns the given channel. func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) { var serverID string err := h.db.QueryRowContext(ctx, ` SELECT server_id FROM channels WHERE id = $1 `, channelID).Scan(&serverID) return serverID, err } // @Summary Create a channel // @Description Create a new channel in a server // @Tags channels // @Accept json // @Produce json // @Security SessionAuth // @Param body body createChannelRequest true "Channel data" // @Success 201 {object} channelResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /servers/{serverID}/channels [post] func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } serverID := chi.URLParam(r, "serverID") if serverID == "" { http.Error(w, `{"error":"serverID is required"}`, http.StatusBadRequest) return } var req createChannelRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Name == "" { http.Error(w, `{"error":"name is required"}`, http.StatusBadRequest) return } member, err := h.isMember(r.Context(), userID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !member { http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) return } channelType := req.Type if channelType == "" { channelType = "text" } category := req.Category if category == "" { category = "general" } position := 0 if req.Position != nil { position = *req.Position } var ch channelResponse err = h.db.QueryRowContext(r.Context(), ` INSERT INTO channels (server_id, name, type, category, position, slowmode_seconds, group_id) VALUES ($1, $2, $3, $4, $5, 0, $6) RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at `, serverID, req.Name, channelType, category, position, req.GroupID).Scan( &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt, ) if err != nil { http.Error(w, `{"error":"failed to create channel (name may already exist in this server)"}`, http.StatusConflict) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(ch) } // @Summary List channels // @Description List channels in a server // @Tags channels // @Produce json // @Security SessionAuth // @Param server_id query string true "Server ID" // @Success 200 {array} channelResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Router /servers/{serverID}/channels [get] func (h *Handler) List(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } serverID := chi.URLParam(r, "serverID") if serverID == "" { http.Error(w, `{"error":"server_id query parameter is required"}`, http.StatusBadRequest) return } member, err := h.isMember(r.Context(), userID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !member { http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) return } rows, err := h.db.QueryContext(r.Context(), ` SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at FROM channels WHERE server_id = $1 ORDER BY position, name `, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } defer rows.Close() channels := make([]channelResponse, 0) for rows.Next() { var ch channelResponse if err := rows.Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } // Check VIEW_CHANNEL permission allowed, err := h.checker.CheckChannelPermission(r.Context(), serverID, userID, ch.ID, permissions.VIEW_CHANNEL) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if allowed { channels = append(channels, ch) } } if err := rows.Err(); err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } // Filter out channels the user doesn't have VIEW_CHANNEL permission for. filtered := make([]channelResponse, 0, len(channels)) for _, ch := range channels { allowed, checkErr := h.checker.CheckChannelPermission(r.Context(), serverID, userID, ch.ID, permissions.VIEW_CHANNEL) if checkErr != nil { continue } if allowed { filtered = append(filtered, ch) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(filtered) } // @Summary Get a channel // @Description Get a channel by ID // @Tags channels // @Produce json // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Success 200 {object} channelResponse // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /servers/{serverID}/channels/{channelID} [get] func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } channelID := chi.URLParam(r, "channelID") var ch channelResponse err := h.db.QueryRowContext(r.Context(), ` SELECT id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at FROM channels WHERE id = $1 `, channelID).Scan(&ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt) 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 } member, err := h.isMember(r.Context(), userID, ch.ServerID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !member { http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ch) } type updateChannelRequest struct { Name *string `json:"name"` Type *string `json:"type"` Category *string `json:"category"` Position *int `json:"position"` SlowmodeSeconds *int `json:"slowmode_seconds"` GroupID *string `json:"group_id"` } // @Summary Update a channel // @Description Update a channel's properties // @Tags channels // @Accept json // @Produce json // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Param body body updateChannelRequest true "Channel update data" // @Success 200 {object} channelResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /servers/{serverID}/channels/{channelID} [patch] func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } channelID := chi.URLParam(r, "channelID") var req updateChannelRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest) return } if req.Name == nil && req.Type == nil && req.Category == nil && req.Position == nil && req.SlowmodeSeconds == nil && req.GroupID == nil { http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest) return } // Look up server ownership and verify membership serverID, err := h.serverIDForChannel(r.Context(), channelID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } // Verify membership and MANAGE_CHANNELS permission member, err := h.isMember(r.Context(), userID, serverID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !member { http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden) return } // Server owner bypasses permission check var ownerID string err = h.db.QueryRowContext(r.Context(), `SELECT owner_id FROM servers WHERE id = $1`, serverID).Scan(&ownerID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if ownerID != userID { allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if !allowed { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } } var ch channelResponse err = h.db.QueryRowContext(r.Context(), ` UPDATE channels SET name = COALESCE($1, name), type = COALESCE($2, type), category = COALESCE($3, category), position = COALESCE($4, position), slowmode_seconds = COALESCE($5, slowmode_seconds), group_id = COALESCE($7, group_id) WHERE id = $6 RETURNING id, server_id, name, type, category, position, slowmode_seconds, group_id, created_at `, req.Name, req.Type, req.Category, req.Position, req.SlowmodeSeconds, channelID, req.GroupID).Scan( &ch.ID, &ch.ServerID, &ch.Name, &ch.Type, &ch.Category, &ch.Position, &ch.SlowmodeSeconds, &ch.GroupID, &ch.CreatedAt, ) if err != nil { http.Error(w, `{"error":"failed to update channel"}`, http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ch) } // @Summary Delete a channel // @Description Delete a channel (server owner only) // @Tags channels // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /servers/{serverID}/channels/{channelID} [delete] func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) return } channelID := chi.URLParam(r, "channelID") serverID, err := h.serverIDForChannel(r.Context(), channelID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound) return } http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } // Only the server owner can delete channels var ownerID string err = h.db.QueryRowContext(r.Context(), ` SELECT owner_id FROM servers WHERE id = $1 `, serverID).Scan(&ownerID) if err != nil { http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError) return } if ownerID != userID { // Not the owner; check MANAGE_CHANNELS permission allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS) if err != nil || !allowed { http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) return } } _, err = h.db.ExecContext(r.Context(), ` DELETE FROM channels WHERE id = $1 `, channelID) if err != nil { http.Error(w, `{"error":"failed to delete channel"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }