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
+56
View File
@@ -0,0 +1,56 @@
package audit
import (
"context"
"database/sql"
"encoding/json"
)
// Action type constants.
const (
ActionKick = "KICK"
ActionBan = "BAN"
ActionUnban = "UNBAN"
ActionMute = "MUTE"
ActionUnmute = "UNMUTE"
ActionRoleCreate = "ROLE_CREATE"
ActionRoleUpdate = "ROLE_UPDATE"
ActionRoleDelete = "ROLE_DELETE"
ActionMemberRoles = "MEMBER_ROLES_UPDATE"
ActionChannelCreate = "CHANNEL_CREATE"
ActionChannelUpdate = "CHANNEL_UPDATE"
ActionChannelDelete = "CHANNEL_DELETE"
ActionServerUpdate = "SERVER_UPDATE"
ActionMessageDelete = "MESSAGE_DELETE"
ActionBulkDelete = "BULK_DELETE"
)
// Logger writes audit log entries to the database.
type Logger struct {
db *sql.DB
}
// NewLogger creates a new audit logger.
func NewLogger(db *sql.DB) *Logger {
return &Logger{db: db}
}
// Log records a single audit entry.
func (l *Logger) Log(ctx context.Context, serverID, userID, actionType, targetType, targetID, reason string, changes map[string]interface{}) error {
if l == nil || l.db == nil {
return nil
}
var changesJSON []byte
var err error
if len(changes) > 0 {
changesJSON, err = json.Marshal(changes)
if err != nil {
return err
}
}
_, err = l.db.ExecContext(ctx, `
INSERT INTO audit_log (server_id, user_id, action_type, target_type, target_id, reason, changes)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, serverID, userID, actionType, targetType, targetID, reason, changesJSON)
return err
}
+125
View File
@@ -0,0 +1,125 @@
package audit
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"strconv"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
// Handler exposes the audit log read API.
type Handler struct {
db *sql.DB
}
// NewHandler creates a new audit log handler.
func NewHandler(db *sql.DB) *Handler {
return &Handler{db: db}
}
// Entry represents a single audit log entry.
type Entry struct {
ID string `json:"id"`
ServerID string `json:"server_id"`
UserID *string `json:"user_id"`
Username *string `json:"username"`
ActionType string `json:"action_type"`
TargetType *string `json:"target_type"`
TargetID *string `json:"target_id"`
Reason *string `json:"reason"`
Changes json.RawMessage `json:"changes"`
CreatedAt string `json:"created_at"`
}
// List handles GET /servers/{serverID}/audit-log.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
serverID := chi.URLParam(r, "serverID")
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
// ponytail: membership check keeps it simple; MANAGE_SERVER permission is the real gate.
var isMember bool
err := h.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM members WHERE server_id = $1 AND user_id = $2)`, serverID, userID).Scan(&isMember)
if err != nil || !isMember {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
actionType := r.URL.Query().Get("action_type")
before := r.URL.Query().Get("before")
args := []interface{}{serverID}
query := `
SELECT a.id, a.server_id, a.user_id, u.username, a.action_type, a.target_type, a.target_id, a.reason, a.changes, a.created_at::text
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
WHERE a.server_id = $1
`
if actionType != "" {
args = append(args, actionType)
query += ` AND a.action_type = $` + strconv.Itoa(len(args))
}
if before != "" {
args = append(args, before)
query += ` AND a.created_at < (SELECT created_at FROM audit_log WHERE id = $` + strconv.Itoa(len(args)) + `)`
}
query += ` ORDER BY a.created_at DESC LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit)
rows, err := h.db.QueryContext(r.Context(), query, args...)
if err != nil {
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
entries := make([]Entry, 0)
for rows.Next() {
var e Entry
var userIDStr, username, targetType, targetID, reason sql.NullString
err := rows.Scan(&e.ID, &e.ServerID, &userIDStr, &username, &e.ActionType, &targetType, &targetID, &reason, &e.Changes, &e.CreatedAt)
if err != nil {
continue
}
if userIDStr.Valid {
e.UserID = &userIDStr.String
}
if username.Valid {
e.Username = &username.String
}
if targetType.Valid {
e.TargetType = &targetType.String
}
if targetID.Valid {
e.TargetID = &targetID.String
}
if reason.Valid {
e.Reason = &reason.String
}
entries = append(entries, e)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
// Cleanup removes audit log entries older than 90 days.
func (h *Handler) Cleanup(ctx context.Context) error {
_, err := h.db.ExecContext(ctx, `DELETE FROM audit_log WHERE created_at < NOW() - INTERVAL '90 days'`)
return err
}
+32
View File
@@ -284,6 +284,38 @@ CREATE INDEX IF NOT EXISTS idx_embeds_message ON embeds(message_id);
-- Channel slowmode
ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER NOT NULL DEFAULT 0;
-- Audit log
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action_type VARCHAR(32) NOT NULL,
target_type VARCHAR(32),
target_id VARCHAR(64),
reason TEXT,
changes JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_server_created ON audit_log(server_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(server_id, action_type);
-- Channel permission overrides
CREATE TABLE IF NOT EXISTS channel_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
target_type VARCHAR(8) NOT NULL CHECK (target_type IN ('role', 'user')),
target_id UUID NOT NULL,
allow_bitflags BIGINT NOT NULL DEFAULT 0,
deny_bitflags BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (channel_id, target_type, target_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel ON channel_overrides(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_overrides_target ON channel_overrides(channel_id, target_type, target_id);
-- Message full-text search
ALTER TABLE messages ADD COLUMN IF NOT EXISTS search_vector tsvector;
CREATE INDEX IF NOT EXISTS idx_messages_search ON messages USING GIN(search_vector);
+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"`