Add anti-raid and anti-nuke protection
Join Gate (account age, avatar, advertising and username filters, bot checks), raid detection with invite pausing, anti-nuke quarantine of admins who mass delete, manual and automatic lockdowns, a decaying heat score against spam, role and channel snapshots with restore, and alert DMs to the owner and trusted users. Everything that weakens protection is limited to the owner and the trusted list. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d294b3463b
commit
3f87e68a2c
26 files changed
+6022
No files matched your search
@@ -0,0 +1,584 @@
|
||||
// Package securityrepo stores the anti-raid and anti-nuke settings, the
|
||||
// trusted list, quarantines, locked channels, the event history and the
|
||||
// dashboard's requests. Discord IDs are int64; 0 means "not set".
|
||||
package securityrepo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo {
|
||||
return &Repo{db: db}
|
||||
}
|
||||
|
||||
// Filter actions.
|
||||
const (
|
||||
ActionOff = "off"
|
||||
ActionAlert = "alert" // raid mode only: alert staff, touch nobody
|
||||
ActionTimeout = "timeout"
|
||||
ActionKick = "kick"
|
||||
ActionBan = "ban"
|
||||
)
|
||||
|
||||
// Anti-nuke limit keys.
|
||||
const (
|
||||
LimitChannelDelete = "channel_delete"
|
||||
LimitChannelCreate = "channel_create"
|
||||
LimitRoleDelete = "role_delete"
|
||||
LimitRoleCreate = "role_create"
|
||||
LimitBan = "ban"
|
||||
LimitKick = "kick"
|
||||
LimitWebhookCreate = "webhook_create"
|
||||
)
|
||||
|
||||
// DefaultLimits mirrors the column default in the migration.
|
||||
func DefaultLimits() map[string]int {
|
||||
return map[string]int{
|
||||
LimitChannelDelete: 2, LimitChannelCreate: 5, LimitRoleDelete: 2, LimitRoleCreate: 5,
|
||||
LimitBan: 3, LimitKick: 5, LimitWebhookCreate: 3,
|
||||
}
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
GuildID int64
|
||||
AlertChannelID int64
|
||||
AlertRoleID int64
|
||||
AlertDM bool
|
||||
|
||||
JoinGateEnabled bool
|
||||
NoAvatar string
|
||||
NewAccount string
|
||||
MinAccountAgeDays int
|
||||
AdvertisingName string
|
||||
UsernameFilter string
|
||||
UsernamePatterns []string
|
||||
UnverifiedBot string
|
||||
UnauthorizedBot string
|
||||
AntiRaidEnabled bool
|
||||
RaidJoinCount int
|
||||
RaidJoinSeconds int
|
||||
RaidAction string
|
||||
RaidPauseInvites bool
|
||||
RaidQuietMinutes int
|
||||
AntiNukeEnabled bool
|
||||
AntiNukeWindowSeconds int
|
||||
AntiNukeLimits map[string]int
|
||||
AntiNukeLockServer bool
|
||||
QuarantineRoleID int64
|
||||
AntiNukeAutoRestore bool
|
||||
HeatEnabled bool
|
||||
HeatStrictness string
|
||||
HeatAction string
|
||||
HeatTimeoutMinutes int
|
||||
HeatEscalate bool
|
||||
HeatBlockedWords []string
|
||||
HeatPanicMembers int
|
||||
HeatLockdownMentions int
|
||||
JoinLock string
|
||||
|
||||
// Configured is false when the guild has no row yet; the fields then
|
||||
// hold the defaults, with every system off.
|
||||
Configured bool
|
||||
}
|
||||
|
||||
func Defaults(guildID int64) *Settings {
|
||||
return &Settings{
|
||||
GuildID: guildID,
|
||||
AlertDM: true,
|
||||
NoAvatar: ActionOff, NewAccount: ActionOff, MinAccountAgeDays: 7, AdvertisingName: ActionOff,
|
||||
UsernameFilter: ActionOff, UnverifiedBot: ActionOff, UnauthorizedBot: ActionKick,
|
||||
RaidJoinCount: 10, RaidJoinSeconds: 10, RaidAction: ActionKick, RaidPauseInvites: true, RaidQuietMinutes: 10,
|
||||
AntiNukeWindowSeconds: 10, AntiNukeLimits: DefaultLimits(),
|
||||
HeatStrictness: "medium", HeatAction: ActionTimeout, HeatTimeoutMinutes: 10, HeatEscalate: true, HeatPanicMembers: 3,
|
||||
JoinLock: ActionOff,
|
||||
}
|
||||
}
|
||||
|
||||
func nullID(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: v != 0} }
|
||||
|
||||
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
|
||||
const q = `
|
||||
SELECT alert_channel_id, alert_role_id, alert_dm,
|
||||
joingate_enabled, joingate_no_avatar, joingate_new_account, joingate_min_account_age_days,
|
||||
joingate_advertising_name, joingate_username_filter, to_json(joingate_username_patterns),
|
||||
joingate_unverified_bot, joingate_unauthorized_bot,
|
||||
antiraid_enabled, raid_join_count, raid_join_seconds, raid_action, raid_pause_invites, raid_quiet_minutes,
|
||||
antinuke_enabled, antinuke_window_seconds, antinuke_limits, antinuke_lock_server, quarantine_role_id,
|
||||
antinuke_auto_restore, heat_enabled, heat_strictness, heat_action, heat_timeout_minutes, heat_escalate,
|
||||
to_json(heat_blocked_words), heat_panic_members, heat_lockdown_mentions,
|
||||
join_lock
|
||||
FROM security_settings WHERE guild_id = $1`
|
||||
s := &Settings{GuildID: guildID, Configured: true}
|
||||
var alertCh, alertRole, qRole sql.NullInt64
|
||||
var patterns, words []byte
|
||||
var limits []byte
|
||||
err := r.db.QueryRowContext(ctx, q, guildID).Scan(
|
||||
&alertCh, &alertRole, &s.AlertDM,
|
||||
&s.JoinGateEnabled, &s.NoAvatar, &s.NewAccount, &s.MinAccountAgeDays,
|
||||
&s.AdvertisingName, &s.UsernameFilter, &patterns,
|
||||
&s.UnverifiedBot, &s.UnauthorizedBot,
|
||||
&s.AntiRaidEnabled, &s.RaidJoinCount, &s.RaidJoinSeconds, &s.RaidAction, &s.RaidPauseInvites, &s.RaidQuietMinutes,
|
||||
&s.AntiNukeEnabled, &s.AntiNukeWindowSeconds, &limits, &s.AntiNukeLockServer, &qRole,
|
||||
&s.AntiNukeAutoRestore, &s.HeatEnabled, &s.HeatStrictness, &s.HeatAction, &s.HeatTimeoutMinutes, &s.HeatEscalate,
|
||||
&words, &s.HeatPanicMembers, &s.HeatLockdownMentions,
|
||||
&s.JoinLock,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Defaults(guildID), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.AlertChannelID, s.AlertRoleID, s.QuarantineRoleID = alertCh.Int64, alertRole.Int64, qRole.Int64
|
||||
_ = json.Unmarshal(patterns, &s.UsernamePatterns)
|
||||
_ = json.Unmarshal(words, &s.HeatBlockedWords)
|
||||
s.AntiNukeLimits = DefaultLimits()
|
||||
if len(limits) > 0 {
|
||||
// Saved values override the defaults key by key, so a limit added
|
||||
// in a later version still has a value for older rows.
|
||||
var saved map[string]int
|
||||
if err := json.Unmarshal(limits, &saved); err == nil {
|
||||
for k, v := range saved {
|
||||
s.AntiNukeLimits[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ensureRow makes sure the guild has a settings row so single-column updates
|
||||
// below can be plain UPDATEs.
|
||||
func (r *Repo) ensureRow(ctx context.Context, guildID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO security_settings (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) update(ctx context.Context, guildID int64, set string, args ...any) error {
|
||||
if err := r.ensureRow(ctx, guildID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE security_settings SET `+set+` WHERE guild_id = $1`, append([]any{guildID}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// Feature names accepted by SetEnabled.
|
||||
const (
|
||||
FeatureJoinGate = "joingate"
|
||||
FeatureAntiRaid = "antiraid"
|
||||
FeatureAntiNuke = "antinuke"
|
||||
FeatureHeat = "heat"
|
||||
)
|
||||
|
||||
func (r *Repo) SetEnabled(ctx context.Context, guildID int64, feature string, on bool) error {
|
||||
col := map[string]string{FeatureJoinGate: "joingate_enabled", FeatureAntiRaid: "antiraid_enabled", FeatureAntiNuke: "antinuke_enabled", FeatureHeat: "heat_enabled"}[feature]
|
||||
if col == "" {
|
||||
return errors.New("unknown feature " + feature)
|
||||
}
|
||||
return r.update(ctx, guildID, col+` = $2`, on)
|
||||
}
|
||||
|
||||
func (r *Repo) SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error {
|
||||
return r.update(ctx, guildID, `alert_channel_id = $2, alert_role_id = $3`, nullID(channelID), nullID(roleID))
|
||||
}
|
||||
|
||||
func (r *Repo) SetQuarantineRole(ctx context.Context, guildID, roleID int64) error {
|
||||
return r.update(ctx, guildID, `quarantine_role_id = $2`, nullID(roleID))
|
||||
}
|
||||
|
||||
func (r *Repo) SetJoinLock(ctx context.Context, guildID int64, mode string) error {
|
||||
return r.update(ctx, guildID, `join_lock = $2`, mode)
|
||||
}
|
||||
|
||||
// ---- Trusted ----
|
||||
|
||||
type Trusted struct {
|
||||
TargetID int64
|
||||
Kind string // "user" or "role"
|
||||
AddedBy int64
|
||||
}
|
||||
|
||||
func (r *Repo) ListTrusted(ctx context.Context, guildID int64) ([]Trusted, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT target_id, kind, added_by FROM security_trusted WHERE guild_id = $1 ORDER BY created_at`, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Trusted
|
||||
for rows.Next() {
|
||||
var t Trusted
|
||||
if err := rows.Scan(&t.TargetID, &t.Kind, &t.AddedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) AddTrusted(ctx context.Context, guildID int64, t Trusted) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_trusted (guild_id, target_id, kind, added_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (guild_id, target_id) DO UPDATE SET kind = EXCLUDED.kind`, guildID, t.TargetID, t.Kind, t.AddedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error) {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM security_trusted WHERE guild_id = $1 AND target_id = $2`, guildID, targetID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ---- Quarantine ----
|
||||
|
||||
type Quarantine struct {
|
||||
GuildID int64
|
||||
UserID int64
|
||||
PreviousRoleIDs []int64
|
||||
Reason string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SaveQuarantine records a quarantine. If the member is already quarantined
|
||||
// the first record is kept, since its roles are the ones to give back.
|
||||
func (r *Repo) SaveQuarantine(ctx context.Context, q Quarantine) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_quarantines (guild_id, user_id, previous_role_ids, reason)
|
||||
VALUES ($1, $2, COALESCE($3::bigint[], '{}'), $4)
|
||||
ON CONFLICT (guild_id, user_id) DO NOTHING`, q.GuildID, q.UserID, q.PreviousRoleIDs, q.Reason)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanQuarantine(row interface{ Scan(...any) error }) (Quarantine, error) {
|
||||
var q Quarantine
|
||||
var roles []byte
|
||||
err := row.Scan(&q.GuildID, &q.UserID, &roles, &q.Reason, &q.CreatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(roles, &q.PreviousRoleIDs)
|
||||
}
|
||||
return q, err
|
||||
}
|
||||
|
||||
// GetQuarantine returns nil when the member isn't quarantined.
|
||||
func (r *Repo) GetQuarantine(ctx context.Context, guildID, userID int64) (*Quarantine, error) {
|
||||
q, err := scanQuarantine(r.db.QueryRowContext(ctx, `
|
||||
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
|
||||
WHERE guild_id = $1 AND user_id = $2`, guildID, userID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func (r *Repo) ListQuarantines(ctx context.Context, guildID int64) ([]Quarantine, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT guild_id, user_id, to_json(previous_role_ids), reason, created_at FROM security_quarantines
|
||||
WHERE guild_id = $1 ORDER BY created_at DESC`, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Quarantine
|
||||
for rows.Next() {
|
||||
q, err := scanQuarantine(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteQuarantine(ctx context.Context, guildID, userID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM security_quarantines WHERE guild_id = $1 AND user_id = $2`, guildID, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Lockdowns ----
|
||||
|
||||
type Lockdown struct {
|
||||
GuildID int64
|
||||
ChannelID int64
|
||||
HadOverwrite bool
|
||||
PreviousAllow int64
|
||||
PreviousDeny int64
|
||||
ServerWide bool
|
||||
LockedBy int64
|
||||
}
|
||||
|
||||
// SaveLockdown remembers a channel's @everyone overwrite from before it was
|
||||
// locked. A channel that is already locked keeps its first record, which is
|
||||
// the state to go back to.
|
||||
func (r *Repo) SaveLockdown(ctx context.Context, l Lockdown) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_lockdowns (guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (guild_id, channel_id) DO NOTHING`,
|
||||
l.GuildID, l.ChannelID, l.HadOverwrite, l.PreviousAllow, l.PreviousDeny, l.ServerWide, l.LockedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLockdown returns nil when the channel isn't locked by the bot.
|
||||
func (r *Repo) GetLockdown(ctx context.Context, guildID, channelID int64) (*Lockdown, error) {
|
||||
var l Lockdown
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
|
||||
FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID).
|
||||
Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// ListLockdowns lists locked channels; serverWideOnly limits it to the ones a
|
||||
// server lockdown locked.
|
||||
func (r *Repo) ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]Lockdown, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT guild_id, channel_id, had_overwrite, previous_allow, previous_deny, server_wide, locked_by
|
||||
FROM security_lockdowns WHERE guild_id = $1 AND (NOT $2 OR server_wide) ORDER BY locked_at`, guildID, serverWideOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Lockdown
|
||||
for rows.Next() {
|
||||
var l Lockdown
|
||||
if err := rows.Scan(&l.GuildID, &l.ChannelID, &l.HadOverwrite, &l.PreviousAllow, &l.PreviousDeny, &l.ServerWide, &l.LockedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) DeleteLockdown(ctx context.Context, guildID, channelID int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM security_lockdowns WHERE guild_id = $1 AND channel_id = $2`, guildID, channelID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Events ----
|
||||
|
||||
type Event struct {
|
||||
GuildID int64
|
||||
Kind string
|
||||
ActorID int64
|
||||
TargetID int64
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Event kinds.
|
||||
const (
|
||||
EventJoinGate = "joingate"
|
||||
EventRaidStarted = "raid_started"
|
||||
EventRaidEnded = "raid_ended"
|
||||
EventNuke = "nuke"
|
||||
EventQuarantined = "quarantined"
|
||||
EventReleased = "released"
|
||||
EventUnauthorizedBot = "unauthorized_bot"
|
||||
EventLocked = "locked"
|
||||
EventUnlocked = "unlocked"
|
||||
EventHeat = "heat"
|
||||
EventHeatPanic = "heat_panic"
|
||||
EventSnapshot = "snapshot"
|
||||
EventRestored = "restored"
|
||||
)
|
||||
|
||||
func (r *Repo) AddEvent(ctx context.Context, e Event) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_events (guild_id, kind, actor_id, target_id, detail) VALUES ($1, $2, $3, $4, $5)`,
|
||||
e.GuildID, e.Kind, nullID(e.ActorID), nullID(e.TargetID), e.Detail)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Dashboard commands ----
|
||||
|
||||
type Command struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
CommandType string
|
||||
Payload []byte
|
||||
RequestedBy int64
|
||||
}
|
||||
|
||||
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, guild_id, command_type, COALESCE(payload, '{}'::jsonb), requested_by
|
||||
FROM security_commands WHERE processed_at IS NULL ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Command
|
||||
for rows.Next() {
|
||||
var c Command
|
||||
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.Payload, &c.RequestedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `UPDATE security_commands SET processed_at = NOW() WHERE id = $1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- Raids in progress ----
|
||||
|
||||
type Raid struct {
|
||||
GuildID int64
|
||||
StartedAt time.Time
|
||||
LastJoinAt time.Time
|
||||
InvitesPaused bool
|
||||
CaughtTotal int
|
||||
}
|
||||
|
||||
func (r *Repo) SaveRaid(ctx context.Context, x Raid) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_raids (guild_id, started_at, last_join_at, invites_paused, caught_total)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (guild_id) DO UPDATE SET
|
||||
last_join_at = EXCLUDED.last_join_at, invites_paused = EXCLUDED.invites_paused, caught_total = EXCLUDED.caught_total`,
|
||||
x.GuildID, x.StartedAt, x.LastJoinAt, x.InvitesPaused, x.CaughtTotal)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListRaids(ctx context.Context) ([]Raid, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT guild_id, started_at, last_join_at, invites_paused, caught_total FROM security_raids`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Raid
|
||||
for rows.Next() {
|
||||
var x Raid
|
||||
if err := rows.Scan(&x.GuildID, &x.StartedAt, &x.LastJoinAt, &x.InvitesPaused, &x.CaughtTotal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteRaid ends a raid's record and its caught list.
|
||||
func (r *Repo) DeleteRaid(ctx context.Context, guildID int64) error {
|
||||
if _, err := r.db.ExecContext(ctx, `DELETE FROM security_raid_caught WHERE guild_id = $1`, guildID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM security_raids WHERE guild_id = $1`, guildID)
|
||||
return err
|
||||
}
|
||||
|
||||
type Caught struct {
|
||||
UserID int64
|
||||
Action string
|
||||
Name string
|
||||
}
|
||||
|
||||
func (r *Repo) AddRaidCaught(ctx context.Context, guildID int64, c Caught) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_raid_caught (guild_id, user_id, action, name) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (guild_id, user_id) DO NOTHING`, guildID, c.UserID, c.Action, c.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repo) ListRaidCaught(ctx context.Context, guildID int64, limit int) ([]Caught, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT user_id, action, name FROM security_raid_caught WHERE guild_id = $1 ORDER BY caught_at LIMIT $2`, guildID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Caught
|
||||
for rows.Next() {
|
||||
var c Caught
|
||||
if err := rows.Scan(&c.UserID, &c.Action, &c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- Snapshots ----
|
||||
|
||||
const keepSnapshots = 3
|
||||
|
||||
type Snapshot struct {
|
||||
ID int64
|
||||
GuildID int64
|
||||
Data []byte
|
||||
Roles int
|
||||
Channels int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// SaveSnapshot stores a snapshot and drops all but the newest three.
|
||||
func (r *Repo) SaveSnapshot(ctx context.Context, s Snapshot) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO security_snapshots (guild_id, data, roles, channels) VALUES ($1, $2, $3, $4)`,
|
||||
s.GuildID, s.Data, s.Roles, s.Channels); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM security_snapshots WHERE guild_id = $1 AND id NOT IN (
|
||||
SELECT id FROM security_snapshots WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2)`,
|
||||
s.GuildID, keepSnapshots)
|
||||
return err
|
||||
}
|
||||
|
||||
// LatestSnapshot returns nil when the guild has none.
|
||||
func (r *Repo) LatestSnapshot(ctx context.Context, guildID int64) (*Snapshot, error) {
|
||||
var s Snapshot
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, guild_id, data, roles, channels, created_at FROM security_snapshots
|
||||
WHERE guild_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1`, guildID).
|
||||
Scan(&s.ID, &s.GuildID, &s.Data, &s.Roles, &s.Channels, &s.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// GuildsDueForSnapshot lists guilds with anti-nuke on whose newest snapshot
|
||||
// is older than maxAge (or that have none).
|
||||
func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ([]int64, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT s.guild_id FROM security_settings s
|
||||
WHERE s.antinuke_enabled AND NOT EXISTS (
|
||||
SELECT 1 FROM security_snapshots p WHERE p.guild_id = s.guild_id AND p.created_at > NOW() - make_interval(secs => $1))`,
|
||||
maxAge.Seconds())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var g int64
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in new issue
Block a user