feat(phase2): bulk delete + audit log; update roadmap/parity docs
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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