diff --git a/irc/channel.go b/irc/channel.go index 73749d6c..1699a2f9 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -232,13 +232,7 @@ func (channel *Channel) ClientIsAtLeast(client *Client, permission modes.Mode) b clientModes := channel.members[client] - // get voice, since it's not a part of ChannelPrivModes - if clientModes.HasMode(permission) { - return true - } - - // check regular modes - for _, mode := range modes.ChannelPrivModes { + for _, mode := range modes.ChannelUserModes { if clientModes.HasMode(mode) { return true } diff --git a/irc/chanserv.go b/irc/chanserv.go index f2306ca7..3decf726 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -5,6 +5,7 @@ package irc import ( "fmt" + "sort" "strings" "github.com/goshuirc/irc-go/ircfmt" @@ -42,6 +43,17 @@ remembered.`, helpShort: `$bREGISTER$b lets you own a given channel.`, authRequired: true, }, + "amode": { + handler: csAmodeHandler, + help: `Syntax: $bAMODE #channel [mode change] [account]$b + +AMODE lists or modifies persistent mode settings that affect channel members. +For example, $bAMODE #channel +o dan$b grants the the holder of the "dan" +account the +o operator mode every time they join #channel. To list current +accounts and modes, use $bAMODE #channel$b. Note that users are always +referenced by their registered account names, not their nicknames.`, + helpShort: `$bAMODE$b modifies persistent mode settings for channel members.`, + }, } ) @@ -50,6 +62,79 @@ func csNotice(rb *ResponseBuffer, text string) { rb.Add(nil, "ChanServ", "NOTICE", rb.target.Nick(), text) } +func csAmodeHandler(server *Server, client *Client, command, params string, rb *ResponseBuffer) { + channelName, modeChange := utils.ExtractParam(params) + + channel := server.channels.Get(channelName) + if channel == nil { + csNotice(rb, client.t("Channel does not exist")) + return + } else if channel.Founder() == "" { + csNotice(rb, client.t("Channel is not registered")) + return + } + + modeChanges, unknown := modes.ParseChannelModeChanges(strings.Fields(modeChange)...) + var change modes.ModeChange + if len(modeChanges) > 1 || len(unknown) > 0 { + csNotice(rb, client.t("Invalid mode change")) + return + } else if len(modeChanges) == 1 { + change = modeChanges[0] + } else { + change = modes.ModeChange{Op: modes.List} + } + + // normalize and validate the account argument + accountIsValid := false + change.Arg, _ = CasefoldName(change.Arg) + switch change.Op { + case modes.List: + accountIsValid = true + case modes.Add: + // if we're adding a mode, the account must exist + if change.Arg != "" { + _, err := server.accounts.LoadAccount(change.Arg) + accountIsValid = (err == nil) + } + case modes.Remove: + // allow removal of accounts that may have been deleted + accountIsValid = (change.Arg != "") + } + if !accountIsValid { + csNotice(rb, client.t("Account does not exist")) + return + } + + affectedModes, err := channel.ProcessAccountToUmodeChange(client, change) + + if err == errInsufficientPrivs { + csNotice(rb, client.t("Insufficient privileges")) + return + } else if err != nil { + csNotice(rb, client.t("Internal error")) + return + } + + switch change.Op { + case modes.List: + // sort the persistent modes in descending order of priority + sort.Slice(affectedModes, func(i, j int) bool { + return umodeGreaterThan(affectedModes[i].Mode, affectedModes[j].Mode) + }) + csNotice(rb, fmt.Sprintf(client.t("Channel %s has %d persistent modes set"), channelName, len(affectedModes))) + for _, modeChange := range affectedModes { + csNotice(rb, fmt.Sprintf(client.t("Account %s receives mode +%s"), modeChange.Arg, string(modeChange.Mode))) + } + case modes.Add, modes.Remove: + if len(affectedModes) > 0 { + csNotice(rb, fmt.Sprintf(client.t("Successfully set mode %s"), change.String())) + } else { + csNotice(rb, client.t("Change was a no-op")) + } + } +} + func csOpHandler(server *Server, client *Client, command, params string, rb *ResponseBuffer) { channelName, clientToOp := utils.ExtractParam(params) diff --git a/irc/errors.go b/irc/errors.go index fbd9f3fd..761f6a00 100644 --- a/irc/errors.go +++ b/irc/errors.go @@ -34,6 +34,7 @@ var ( errNoExistingBan = errors.New("Ban does not exist") errNoSuchChannel = errors.New("No such channel") errRenamePrivsNeeded = errors.New("Only chanops can rename channels") + errInsufficientPrivs = errors.New("Insufficient privileges") errSaslFail = errors.New("SASL failed") ) diff --git a/irc/modes.go b/irc/modes.go index 576d0375..265f118d 100644 --- a/irc/modes.go +++ b/irc/modes.go @@ -240,3 +240,79 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c return applied } + +// tests whether l > r, in the channel-user mode ordering (e.g., Halfop > Voice) +func umodeGreaterThan(l modes.Mode, r modes.Mode) bool { + for _, mode := range modes.ChannelUserModes { + if l == mode && r != mode { + return true + } else if r == mode { + return false + } + } + return false +} + +// ProcessAccountToUmodeChange processes Add/Remove/List operations for channel persistent usermodes. +func (channel *Channel) ProcessAccountToUmodeChange(client *Client, change modes.ModeChange) (results []modes.ModeChange, err error) { + umodeGEQ := func(l modes.Mode, r modes.Mode) bool { + return l == r || umodeGreaterThan(l, r) + } + + account := client.Account() + isOperChange := client.HasRoleCapabs("chanreg") + + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + + clientMode := channel.accountToUMode[account] + targetModeNow := channel.accountToUMode[change.Arg] + var targetModeAfter modes.Mode + if change.Op == modes.Add { + targetModeAfter = change.Mode + } + + // operators and founders can do anything + hasPrivs := isOperChange || (account != "" && account == channel.registeredFounder) + // halfop and up can list, and do add/removes at levels <= their own + if change.Op == modes.List && umodeGEQ(clientMode, modes.Halfop) { + hasPrivs = true + } else if umodeGEQ(clientMode, modes.Halfop) && umodeGEQ(clientMode, targetModeNow) && umodeGEQ(clientMode, targetModeAfter) { + hasPrivs = true + } + if !hasPrivs { + return nil, errInsufficientPrivs + } + + switch change.Op { + case modes.Add: + if targetModeNow != targetModeAfter { + channel.accountToUMode[change.Arg] = change.Mode + go client.server.channelRegistry.StoreChannel(channel, IncludeLists) + return []modes.ModeChange{change}, nil + } + return nil, nil + case modes.Remove: + if targetModeNow == change.Mode { + delete(channel.accountToUMode, change.Arg) + go client.server.channelRegistry.StoreChannel(channel, IncludeLists) + return []modes.ModeChange{change}, nil + } + return nil, nil + case modes.List: + result := make([]modes.ModeChange, len(channel.accountToUMode)) + pos := 0 + for account, mode := range channel.accountToUMode { + result[pos] = modes.ModeChange{ + Mode: mode, + Arg: account, + Op: modes.Add, + } + pos++ + } + return result, nil + default: + // shouldn't happen + return nil, errInvalidCharacter + } +} diff --git a/irc/modes/modes.go b/irc/modes/modes.go index 310e46f4..481ee3d4 100644 --- a/irc/modes/modes.go +++ b/irc/modes/modes.go @@ -147,6 +147,12 @@ var ( ChannelFounder, ChannelAdmin, ChannelOperator, Halfop, } + // ChannelUserModes holds the list of all modes that can be applied to a user in a channel, + // including Voice, in descending order of precedence + ChannelUserModes = Modes{ + ChannelFounder, ChannelAdmin, ChannelOperator, Halfop, Voice, + } + ChannelModePrefixes = map[Mode]string{ ChannelFounder: "~", ChannelAdmin: "&", @@ -176,20 +182,13 @@ func SplitChannelMembershipPrefixes(target string) (prefixes string, name string } // GetLowestChannelModePrefix returns the lowest channel prefix mode out of the given prefixes. -func GetLowestChannelModePrefix(prefixes string) *Mode { - var lowest *Mode - - if strings.Contains(prefixes, "+") { - lowest = &Voice - } else { - for i, mode := range ChannelPrivModes { - if strings.Contains(prefixes, ChannelModePrefixes[mode]) { - lowest = &ChannelPrivModes[i] - } +func GetLowestChannelModePrefix(prefixes string) (lowest *Mode) { + for i, mode := range ChannelUserModes { + if strings.Contains(prefixes, ChannelModePrefixes[mode]) { + lowest = &ChannelPrivModes[i] } } - - return lowest + return } // @@ -304,15 +303,12 @@ func ParseChannelModeChanges(params ...string) (ModeChanges, map[rune]bool) { break } } - for _, supportedMode := range ChannelPrivModes { + for _, supportedMode := range ChannelUserModes { if rune(supportedMode) == mode { isKnown = true break } } - if mode == rune(Voice) { - isKnown = true - } if !isKnown { unknown[mode] = true continue @@ -405,14 +401,11 @@ func (set *ModeSet) Prefixes(isMultiPrefix bool) (prefixes string) { defer set.RUnlock() // add prefixes in order from highest to lowest privs - for _, mode := range ChannelPrivModes { + for _, mode := range ChannelUserModes { if set.modes[mode] { prefixes += ChannelModePrefixes[mode] } } - if set.modes[Voice] { - prefixes += ChannelModePrefixes[Voice] - } if !isMultiPrefix && len(prefixes) > 1 { prefixes = string(prefixes[0]) diff --git a/irc/modes/modes_test.go b/irc/modes/modes_test.go index 216add3c..1faa47d8 100644 --- a/irc/modes/modes_test.go +++ b/irc/modes/modes_test.go @@ -8,6 +8,47 @@ import ( "testing" ) +func TestParseChannelModeChanges(t *testing.T) { + modes, unknown := ParseChannelModeChanges("+h", "wrmsr") + if len(unknown) > 0 { + t.Errorf("unexpected unknown mode change: %v", unknown) + } + expected := ModeChange{ + Op: Add, + Mode: Halfop, + Arg: "wrmsr", + } + if len(modes) != 1 || modes[0] != expected { + t.Errorf("unexpected mode change: %v", modes) + } + + modes, unknown = ParseChannelModeChanges("-v", "shivaram") + if len(unknown) > 0 { + t.Errorf("unexpected unknown mode change: %v", unknown) + } + expected = ModeChange{ + Op: Remove, + Mode: Voice, + Arg: "shivaram", + } + if len(modes) != 1 || modes[0] != expected { + t.Errorf("unexpected mode change: %v", modes) + } + + modes, unknown = ParseChannelModeChanges("+tx") + if len(unknown) != 1 || !unknown['x'] { + t.Errorf("expected that x is an unknown mode, instead: %v", unknown) + } + expected = ModeChange{ + Op: Add, + Mode: OpOnlyTopic, + Arg: "", + } + if len(modes) != 1 || modes[0] != expected { + t.Errorf("unexpected mode change: %v", modes) + } +} + func TestSetMode(t *testing.T) { set := NewModeSet() diff --git a/irc/modes_test.go b/irc/modes_test.go index c4a042b9..a929aa10 100644 --- a/irc/modes_test.go +++ b/irc/modes_test.go @@ -36,3 +36,17 @@ func TestParseDefaultChannelModes(t *testing.T) { } } } + +func TestUmodeGreaterThan(t *testing.T) { + if !umodeGreaterThan(modes.Halfop, modes.Voice) { + t.Errorf("expected Halfop > Voice") + } + + if !umodeGreaterThan(modes.Voice, modes.Mode(0)) { + t.Errorf("expected Voice > 0 (the zero value of modes.Mode)") + } + + if umodeGreaterThan(modes.ChannelAdmin, modes.ChannelAdmin) { + t.Errorf("modes should not be greater than themselves") + } +} diff --git a/oragono.yaml b/oragono.yaml index 69f6400c..e027ca91 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -284,6 +284,7 @@ oper-classes: - "sajoin" - "samode" - "vhosts" + - "chanreg" # ircd operators opers: