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)
|
|
}
|