10a702f732
Channel Update handler required MANAGE_CHANNELS role permission. Server owner was getting 'forbidden' because their roles didn't include that bit. Added owner bypass (same pattern as Delete).
450 lines
14 KiB
Go
450 lines
14 KiB
Go
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)
|
|
}
|