399 lines
12 KiB
Go
399 lines
12 KiB
Go
package server
|
|
|
|
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"
|
|
)
|
|
|
|
// RoleHandler manages role CRUD and member-role assignment.
|
|
type RoleHandler struct {
|
|
db *sql.DB
|
|
checker *permissions.Checker
|
|
}
|
|
|
|
// NewRoleHandler creates a new RoleHandler.
|
|
func NewRoleHandler(db *sql.DB) *RoleHandler {
|
|
return &RoleHandler{
|
|
db: db,
|
|
checker: permissions.NewChecker(db),
|
|
}
|
|
}
|
|
|
|
// RegisterRoleRoutes mounts role and member-role endpoints on the given router.
|
|
func (h *RoleHandler) RegisterRoleRoutes(r chi.Router) {
|
|
r.Route("/servers/{serverID}/roles", func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth)
|
|
r.Post("/", h.CreateRole)
|
|
r.Get("/", h.ListRoles)
|
|
r.Patch("/{roleID}", h.UpdateRole)
|
|
r.Delete("/{roleID}", h.DeleteRole)
|
|
})
|
|
|
|
r.Route("/servers/{serverID}/members/{userID}/roles", func(r chi.Router) {
|
|
r.Use(middleware.RequireAuth)
|
|
r.Put("/", h.SetMemberRoles)
|
|
r.Get("/", h.GetMemberRoles)
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Request / response types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type createRoleRequest struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
Permissions int64 `json:"permissions"`
|
|
Position int `json:"position"`
|
|
}
|
|
|
|
type updateRoleRequest struct {
|
|
Name *string `json:"name"`
|
|
Color *string `json:"color"`
|
|
Permissions *int64 `json:"permissions"`
|
|
Position *int `json:"position"`
|
|
}
|
|
|
|
type roleResponse struct {
|
|
ID string `json:"id"`
|
|
ServerID string `json:"server_id"`
|
|
Name string `json:"name"`
|
|
Color *string `json:"color"`
|
|
Permissions int64 `json:"permissions"`
|
|
Position int `json:"position"`
|
|
IsDefault bool `json:"is_default"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
type setMemberRolesRequest struct {
|
|
RoleIDs []string `json:"role_ids"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// requireManageServer checks that the caller has MANAGE_SERVER or MANAGE_ROLES permission.
|
|
// Returns false (and writes the error response) if denied.
|
|
func (h *RoleHandler) requireManageServer(w http.ResponseWriter, r *http.Request, serverID string) bool {
|
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return false
|
|
}
|
|
|
|
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_ROLES)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
if !allowed {
|
|
allowed, err = h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_SERVER)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
}
|
|
if !allowed {
|
|
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// scanRole scans a single role row into a roleResponse.
|
|
func scanRole(row interface{ Scan(dest ...any) error }) (roleResponse, error) {
|
|
var role roleResponse
|
|
err := row.Scan(
|
|
&role.ID, &role.ServerID, &role.Name, &role.Color,
|
|
&role.Permissions, &role.Position, &role.IsDefault, &role.CreatedAt,
|
|
)
|
|
return role, err
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// CreateRole handles POST /servers/{serverID}/roles.
|
|
func (h *RoleHandler) CreateRole(w http.ResponseWriter, r *http.Request) {
|
|
serverID := chi.URLParam(r, "serverID")
|
|
if !h.requireManageServer(w, r, serverID) {
|
|
return
|
|
}
|
|
|
|
var req createRoleRequest
|
|
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
|
|
}
|
|
|
|
role, err := scanRole(h.db.QueryRowContext(r.Context(), `
|
|
INSERT INTO roles (server_id, name, color, permissions, position)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, server_id, name, color, permissions, position, is_default, created_at
|
|
`, serverID, req.Name, req.Color, req.Permissions, req.Position))
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to create role"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(role)
|
|
}
|
|
|
|
// ListRoles handles GET /servers/{serverID}/roles.
|
|
func (h *RoleHandler) ListRoles(w http.ResponseWriter, r *http.Request) {
|
|
serverID := chi.URLParam(r, "serverID")
|
|
|
|
rows, err := h.db.QueryContext(r.Context(), `
|
|
SELECT id, server_id, name, color, permissions, position, is_default, created_at
|
|
FROM roles
|
|
WHERE server_id = $1
|
|
ORDER BY position DESC, name
|
|
`, serverID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
roles := make([]roleResponse, 0)
|
|
for rows.Next() {
|
|
role, err := scanRole(rows)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
roles = append(roles, role)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(roles)
|
|
}
|
|
|
|
// UpdateRole handles PATCH /servers/{serverID}/roles/{roleID}.
|
|
func (h *RoleHandler) UpdateRole(w http.ResponseWriter, r *http.Request) {
|
|
serverID := chi.URLParam(r, "serverID")
|
|
roleID := chi.URLParam(r, "roleID")
|
|
if !h.requireManageServer(w, r, serverID) {
|
|
return
|
|
}
|
|
|
|
var req updateRoleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Name == nil && req.Color == nil && req.Permissions == nil && req.Position == nil {
|
|
http.Error(w, `{"error":"nothing to update"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
role, err := scanRole(h.db.QueryRowContext(r.Context(), `
|
|
UPDATE roles
|
|
SET name = COALESCE($1, name),
|
|
color = COALESCE($2, color),
|
|
permissions = COALESCE($3, permissions),
|
|
position = COALESCE($4, position)
|
|
WHERE id = $5 AND server_id = $6
|
|
RETURNING id, server_id, name, color, permissions, position, is_default, created_at
|
|
`, req.Name, req.Color, req.Permissions, req.Position, roleID, serverID))
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, `{"error":"role not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"failed to update role"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(role)
|
|
}
|
|
|
|
// DeleteRole handles DELETE /servers/{serverID}/roles/{roleID}.
|
|
func (h *RoleHandler) DeleteRole(w http.ResponseWriter, r *http.Request) {
|
|
serverID := chi.URLParam(r, "serverID")
|
|
roleID := chi.URLParam(r, "roleID")
|
|
if !h.requireManageServer(w, r, serverID) {
|
|
return
|
|
}
|
|
|
|
// Prevent deleting the @everyone (default) role.
|
|
var isDefault bool
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
SELECT is_default FROM roles WHERE id = $1 AND server_id = $2
|
|
`, roleID, serverID).Scan(&isDefault)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, `{"error":"role not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if isDefault {
|
|
http.Error(w, `{"error":"cannot delete the default @everyone role"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
_, err = h.db.ExecContext(r.Context(), `
|
|
DELETE FROM roles WHERE id = $1 AND server_id = $2
|
|
`, roleID, serverID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to delete role"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// SetMemberRoles handles PUT /servers/{serverID}/members/{userID}/roles.
|
|
func (h *RoleHandler) SetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
|
serverID := chi.URLParam(r, "serverID")
|
|
targetUserID := chi.URLParam(r, "userID")
|
|
if !h.requireManageServer(w, r, serverID) {
|
|
return
|
|
}
|
|
|
|
// Verify target is a member of the server.
|
|
var exists bool
|
|
err := h.db.QueryRowContext(r.Context(), `
|
|
SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2)
|
|
`, targetUserID, serverID).Scan(&exists)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !exists {
|
|
http.Error(w, `{"error":"user is not a member of this server"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var req setMemberRolesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tx, err := h.db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Remove existing roles for this member in this server.
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
DELETE FROM member_roles WHERE user_id = $1 AND server_id = $2
|
|
`, targetUserID, serverID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Insert new roles (skip the default role — it's implicit).
|
|
for _, roleID := range req.RoleIDs {
|
|
_, err = tx.ExecContext(r.Context(), `
|
|
INSERT INTO member_roles (user_id, server_id, role_id)
|
|
VALUES ($1, $2, $3)
|
|
`, targetUserID, serverID, roleID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to assign role"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GetMemberRoles handles GET /servers/{serverID}/members/{userID}/roles.
|
|
func (h *RoleHandler) GetMemberRoles(w http.ResponseWriter, r *http.Request) {
|
|
serverID := chi.URLParam(r, "serverID")
|
|
targetUserID := chi.URLParam(r, "userID")
|
|
|
|
rows, err := h.db.QueryContext(r.Context(), `
|
|
SELECT r.id, r.server_id, r.name, r.color, r.permissions, r.position, r.is_default, r.created_at
|
|
FROM roles r
|
|
INNER JOIN member_roles mr ON mr.role_id = r.id
|
|
WHERE mr.user_id = $1 AND mr.server_id = $2
|
|
ORDER BY r.position DESC, r.name
|
|
`, targetUserID, serverID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
roles := make([]roleResponse, 0)
|
|
for rows.Next() {
|
|
role, err := scanRole(rows)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
roles = append(roles, role)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(roles)
|
|
}
|
|
|
|
// SeedDefaultRole inserts the @everyone, Admin, and Moderator roles for a newly created server.
|
|
// Call this after creating a server (e.g. from the server creation handler).
|
|
func SeedDefaultRole(ctx context.Context, db *sql.DB, serverID string) error {
|
|
// @everyone
|
|
_, err := db.ExecContext(ctx, `
|
|
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
|
VALUES ($1, '@everyone', $2, 0, TRUE, NULL)
|
|
`, serverID, permissions.DefaultEveryonePermissions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Admin
|
|
adminPerms := permissions.ADMINISTRATOR
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
|
VALUES ($1, 'Admin', $2, 99, FALSE, '#e06c75')
|
|
`, serverID, adminPerms)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Moderator
|
|
modPerms := permissions.VIEW_CHANNEL | permissions.SEND_MESSAGES | permissions.MANAGE_MESSAGES | permissions.KICK_MEMBERS | permissions.BAN_MEMBERS | permissions.MUTE_MEMBERS | permissions.CONNECT_VOICE | permissions.SPEAK_VOICE
|
|
_, err = db.ExecContext(ctx, `
|
|
INSERT INTO roles (server_id, name, permissions, position, is_default, color)
|
|
VALUES ($1, 'Moderator', $2, 50, FALSE, '#61afef')
|
|
`, serverID, modPerms)
|
|
|
|
return err
|
|
}
|