package security import ( "context" "fmt" "log" "velox-bot/internal/db/repos/securityrepo" "github.com/bwmarrin/discordgo" ) // LockBits are what a lockdown denies @everyone: talking, threads, reactions. const LockBits = discordgo.PermissionSendMessages | discordgo.PermissionSendMessagesInThreads | discordgo.PermissionCreatePublicThreads | discordgo.PermissionCreatePrivateThreads | discordgo.PermissionAddReactions // lockable reports whether a channel type has a chat to lock. func lockable(t discordgo.ChannelType) bool { switch t { case discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum, discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice, discordgo.ChannelTypeGuildMedia: return true } return false } func everyoneOverwrite(ch *discordgo.Channel, guildID string) (allow, deny int64, found bool) { for _, o := range ch.PermissionOverwrites { if o.ID == guildID && o.Type == discordgo.PermissionOverwriteTypeRole { return o.Allow, o.Deny, true } } return 0, 0, false } // LockChannel stops @everyone from talking in a channel, remembering the // overwrite it had so UnlockChannel can put it back. func (e *Engine) LockChannel(ctx context.Context, guildID, channelID, by string, serverWide bool) error { ch, err := e.d.Channel(channelID) if err != nil { return fmt.Errorf("looking up the channel: %w", err) } // Never act on a channel of another server, whatever ID was passed in. if ch.GuildID != guildID { return UserError("That channel isn't in this server.") } if !lockable(ch.Type) { return UserError("That channel has no chat to lock.") } allow, deny, had := everyoneOverwrite(ch, guildID) if err := e.st.SaveLockdown(ctx, securityrepo.Lockdown{ GuildID: parseID(guildID), ChannelID: parseID(channelID), HadOverwrite: had, PreviousAllow: allow, PreviousDeny: deny, ServerWide: serverWide, LockedBy: parseID(by), }); err != nil { return err } if err := e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow&^LockBits, deny|LockBits, discordgo.WithAuditLogReason("Velox lockdown")); err != nil { _ = e.st.DeleteLockdown(ctx, parseID(guildID), parseID(channelID)) return fmt.Errorf("locking: %w", err) } if !serverWide { e.event(ctx, guildID, securityrepo.EventLocked, by, channelID, "channel") } return nil } // UnlockChannel restores the lock bits of @everyone's overwrite to what they // were before. Anything else changed in the overwrite during the lockdown is // kept. Unlocking relaxes protection, so callers must check the actor first. func (e *Engine) UnlockChannel(ctx context.Context, guildID, channelID, by string) error { rec, err := e.st.GetLockdown(ctx, parseID(guildID), parseID(channelID)) if err != nil { return err } if rec == nil { return UserError("That channel isn't locked by Velox.") } ch, err := e.d.Channel(channelID) if err != nil { // The channel is gone: nothing to unlock. return e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID) } allow, deny, _ := everyoneOverwrite(ch, guildID) allow = allow&^LockBits | rec.PreviousAllow&LockBits deny = deny&^LockBits | rec.PreviousDeny&LockBits reason := discordgo.WithAuditLogReason("Velox unlock") if allow == 0 && deny == 0 && !rec.HadOverwrite { err = e.d.ChannelPermissionDelete(channelID, guildID, reason) } else { err = e.d.ChannelPermissionSet(channelID, guildID, discordgo.PermissionOverwriteTypeRole, allow, deny, reason) } if err != nil { return fmt.Errorf("unlocking: %w", err) } if err := e.st.DeleteLockdown(ctx, rec.GuildID, rec.ChannelID); err != nil { return err } if !rec.ServerWide { e.event(ctx, guildID, securityrepo.EventUnlocked, by, channelID, "channel") } return nil } // LockServer locks every channel with a chat. func (e *Engine) LockServer(ctx context.Context, guildID, by string) (locked, failed int, err error) { chans, err := e.d.GuildChannels(guildID) if err != nil { return 0, 0, fmt.Errorf("listing channels: %w", err) } for _, ch := range chans { if !lockable(ch.Type) { continue } if err := e.LockChannel(ctx, guildID, ch.ID, by, true); err != nil { log.Printf("security: locking channel %s in guild %s: %v", ch.ID, guildID, err) failed++ continue } locked++ } e.event(ctx, guildID, securityrepo.EventLocked, by, "", fmt.Sprintf("server: %d channels locked, %d failed", locked, failed)) return locked, failed, nil } // UnlockServer unlocks every channel the last server lockdown locked. func (e *Engine) UnlockServer(ctx context.Context, guildID, by string) (unlocked, failed int, err error) { recs, err := e.st.ListLockdowns(ctx, parseID(guildID), true) if err != nil { return 0, 0, err } if len(recs) == 0 { return 0, 0, UserError("The server isn't locked by Velox.") } for _, r := range recs { if err := e.UnlockChannel(ctx, guildID, id(r.ChannelID), by); err != nil { log.Printf("security: unlocking channel %d in guild %s: %v", r.ChannelID, guildID, err) failed++ continue } unlocked++ } e.event(ctx, guildID, securityrepo.EventUnlocked, by, "", fmt.Sprintf("server: %d channels unlocked, %d failed", unlocked, failed)) return unlocked, failed, nil } // SetJoinLock turns the join lockdown on ("kick" or "ban") or off. Turning it // off relaxes protection, so callers must check the actor first. func (e *Engine) SetJoinLock(ctx context.Context, guildID, mode, by string) error { switch mode { case securityrepo.ActionOff, securityrepo.ActionKick, securityrepo.ActionBan: default: return UserError("Joins can be locked with kick or ban.") } if err := e.st.SetJoinLock(ctx, parseID(guildID), mode); err != nil { return err } e.Invalidate(guildID) kind := securityrepo.EventLocked if mode == securityrepo.ActionOff { kind = securityrepo.EventUnlocked } e.event(ctx, guildID, kind, by, "", "joins: "+mode) return nil }