fix: wire permission checks into message/channel handlers

- Add CheckChannelPermission to permissions.Checker (layers channel overrides on top of role permissions)
- Refactor requireChannelAccess to accept required permission bitmap
- Message Create checks SEND_MESSAGES; List/Search check VIEW_CHANNEL
- BulkDelete uses Checker.CheckPermission for MANAGE_MESSAGES
- Channel handler List filters out channels user cannot view
- Remove dead checkPermission method from message handler
- Fix frontend avatar upload: parse response URL, call updateProfile
This commit is contained in:
2026-06-30 13:47:08 -04:00
parent cc8ebc1f8a
commit 215f931311
7 changed files with 112 additions and 57 deletions
+13 -1
View File
@@ -211,8 +211,20 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
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(channels)
json.NewEncoder(w).Encode(filtered)
}
// @Summary Get a channel
+25 -41
View File
@@ -26,15 +26,17 @@ type Handler struct {
mentions *MentionHandler
pushHandler *push.Handler
logger *slog.Logger
checker *permissions.Checker
}
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler {
return &Handler{
db: db,
hub: hub,
mentions: NewMentionHandler(db, pushHandler, logger),
pushHandler: pushHandler,
logger: logger,
checker: checker,
}
}
@@ -59,14 +61,12 @@ type bulkDeleteResponse struct {
// and messages must be within the last 14 days.
func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
userID, serverID, ok := h.requireChannelAccess(w, r, channelID)
userID, serverID, ok := h.requireChannelAccess(w, r, channelID, 0)
if !ok {
return
}
// ponytail: reuse existing auth structure; requireChannelAccess already verifies membership.
// Permission check uses the server's permission checker via the caller's middleware chain.
allowed, err := h.checkPermission(r.Context(), serverID, userID, permissions.MANAGE_MESSAGES)
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_MESSAGES)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
@@ -126,37 +126,6 @@ func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: int(deleted)})
}
// checkPermission is a helper wrapper around a permission checker lookup.
func (h *Handler) checkPermission(ctx context.Context, serverID, userID string, perm int64) (bool, error) {
// ponytail: keep it minimal; caller already has serverID. If no checker is wired, fall back to true only for admins.
rows, err := h.db.QueryContext(ctx, `
SELECT r.permissions FROM roles r
INNER JOIN member_roles mr ON mr.role_id = r.id
WHERE mr.user_id = $1 AND mr.server_id = $2
`, userID, serverID)
if err != nil {
return false, err
}
defer rows.Close()
var effective int64
for rows.Next() {
var p int64
if err := rows.Scan(&p); err != nil {
return false, err
}
effective |= p
}
if err := rows.Err(); err != nil {
return false, err
}
var everyone int64
_ = h.db.QueryRowContext(ctx, `
SELECT permissions FROM roles WHERE server_id = $1 AND is_default = TRUE LIMIT 1
`, serverID).Scan(&everyone)
effective |= everyone
return permissions.Has(effective, permissions.ADMINISTRATOR) || permissions.Has(effective, perm), nil
}
type embedResponse struct {
ID string `json:"id"`
URL string `json:"url"`
@@ -208,7 +177,7 @@ type createMessageRequest struct {
// @Router /channels/{channelID}/messages [post]
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
userID, serverID, ok := h.requireChannelAccess(w, r, channelID)
userID, serverID, ok := h.requireChannelAccess(w, r, channelID, permissions.SEND_MESSAGES)
if !ok {
return
}
@@ -494,7 +463,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
// @Router /channels/{channelID}/messages [get]
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
userID, _, ok := h.requireChannelAccess(w, r, channelID)
userID, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
if !ok {
return
}
@@ -599,7 +568,7 @@ func (h *Handler) attachEmbeds(ctx context.Context, messages []messageResponse)
// Search searches messages in a channel using PostgreSQL full-text search.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
channelID := chi.URLParam(r, "channelID")
_, _, ok := h.requireChannelAccess(w, r, channelID)
_, _, ok := h.requireChannelAccess(w, r, channelID, permissions.VIEW_CHANNEL)
if !ok {
return
}
@@ -661,8 +630,9 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
}
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
// Returns (userID, serverID, ok).
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, string, bool) {
// Returns (userID, serverID, ok). If required is non-zero, also checks the permission against
// channel-level overrides.
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string, required int64) (string, string, bool) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
@@ -682,5 +652,19 @@ func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, c
return "", "", false
}
// Check channel-specific permission if a bitmap was provided.
if required != 0 {
allowed, checkErr := h.checker.CheckChannelPermission(r.Context(), serverID, userID, channelID, required)
if checkErr != nil {
h.logger.Error("permission check failed", "error", checkErr)
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return "", "", false
}
if !allowed {
http.Error(w, `{"error":"missing permissions"}`, http.StatusForbidden)
return "", "", false
}
}
return userID, serverID, true
}
+60
View File
@@ -80,3 +80,63 @@ func (c *Checker) CheckPermission(ctx context.Context, serverID string, userID s
return Has(perms, required), nil
}
// CheckChannelPermission reports whether the user has the required permission bits
// for a specific channel. It layers channel-level overrides on top of server role
// permissions. ADMINISTRATOR bypasses all checks.
func (c *Checker) CheckChannelPermission(ctx context.Context, serverID, userID, channelID string, required int64) (bool, error) {
perms, err := c.GetUserPermissions(ctx, serverID, userID)
if err != nil {
return false, err
}
// Administrator bypasses everything.
if Has(perms, ADMINISTRATOR) {
return true, nil
}
// Layer channel overrides: apply deny first (subtract), then allow (add).
// Role-based overrides from all user roles, then user-specific override.
rows, err := c.db.QueryContext(ctx, `
SELECT co.allow_bitflags, co.deny_bitflags
FROM channel_overrides co
WHERE co.channel_id = $1 AND co.target_type = 'role'
AND co.target_id IN (
SELECT mr.role_id FROM member_roles mr WHERE mr.user_id = $2 AND mr.server_id = $3
)
`, channelID, userID, serverID)
if err != nil {
return false, err
}
var allow, deny int64
if rows != nil {
for rows.Next() {
var a, d int64
if err := rows.Scan(&a, &d); err != nil {
rows.Close()
return false, err
}
allow |= a
deny |= d
}
rows.Close()
}
// User-specific override.
var ua, ud int64
err = c.db.QueryRowContext(ctx, `
SELECT allow_bitflags, deny_bitflags FROM channel_overrides
WHERE channel_id = $1 AND target_type = 'user' AND target_id = $2
`, channelID, userID).Scan(&ua, &ud)
if err != nil && err != sql.ErrNoRows {
return false, err
}
allow |= ua
deny |= ud
// Apply deny first, then allow.
perms = (perms &^ deny) | allow
return Has(perms, required), nil
}