feat(phase2): bulk delete + audit log; update roadmap/parity docs
This commit is contained in:
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user