feat(phase2): bulk delete + audit log; update roadmap/parity docs

This commit is contained in:
2026-06-30 10:20:57 -04:00
parent d3b7f39b9e
commit d7c84647e5
12 changed files with 660 additions and 77 deletions
+113
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
@@ -14,6 +15,7 @@ import (
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/moderation"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
"github.com/go-chi/chi/v5"
)
@@ -46,10 +48,121 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Get("/search", h.Search)
r.Post("/bulk-delete", h.BulkDelete)
r.Patch("/{messageID}", h.Update)
r.Delete("/{messageID}", h.Delete)
}
type bulkDeleteRequest struct {
Messages []string `json:"messages"`
}
type bulkDeleteResponse struct {
Deleted int `json:"deleted"`
}
// BulkDelete deletes up to 100 messages in a channel. Requires MANAGE_MESSAGES
// 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)
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)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
if !allowed {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
var req bulkDeleteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
if len(req.Messages) == 0 {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(bulkDeleteResponse{Deleted: 0})
return
}
if len(req.Messages) > 100 {
http.Error(w, `{"error":"too many messages (max 100)"}`, http.StatusBadRequest)
return
}
// ponytail: simple IN query with 14-day guard and channel_id filter in DB.
placeholders := make([]string, len(req.Messages))
args := make([]interface{}, 0, len(req.Messages)+1)
for i, id := range req.Messages {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args = append(args, id)
}
args = append(args, channelID)
query := fmt.Sprintf(`
DELETE FROM messages
WHERE id IN (%s) AND channel_id = $%d AND created_at > NOW() - INTERVAL '14 days'
`, strings.Join(placeholders, ","), len(args))
res, err := h.db.ExecContext(r.Context(), query, args...)
if err != nil {
http.Error(w, `{"error":"failed to delete messages"}`, http.StatusInternalServerError)
return
}
deleted, _ := res.RowsAffected()
if h.hub != nil {
for _, id := range req.Messages {
h.hub.BroadcastToServer(serverID, gateway.Event{
Type: gateway.EventMessageDelete,
Data: map[string]string{
"id": id,
"channel_id": channelID,
},
})
}
}
w.Header().Set("Content-Type", "application/json")
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"`