From 1016f86f70c155222f62e77d9a6e0906aa01b0d4 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 23 May 2018 15:35:50 -0400 Subject: [PATCH 1/3] implement CHANSERV AMODE --- irc/channel.go | 8 +---- irc/chanserv.go | 78 ++++++++++++++++++++++++++++++++++++++++++++++ irc/getters.go | 34 ++++++++++++++++++++ irc/modes/modes.go | 33 ++++++++------------ 4 files changed, 126 insertions(+), 27 deletions(-) diff --git a/irc/channel.go b/irc/channel.go index a48e7d8b..2ea7969a 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..cd6cdd6f 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,18 @@ 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.`, + authRequired: true, + }, } ) @@ -50,6 +63,71 @@ 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 + } + + clientAccount := client.Account() + if clientAccount == "" || clientAccount != channel.Founder() { + csNotice(rb, client.t("You must be the channel founder to use AMODE")) + return + } + + modeChanges, unknown := modes.ParseChannelModeChanges(strings.Fields(modeChange)...) + + if len(modeChanges) > 1 || len(unknown) > 0 { + csNotice(rb, client.t("Invalid mode change")) + return + } + + if len(modeChanges) == 0 || modeChanges[0].Op == modes.List { + persistentModes := channel.AccountToUmode() + // sort the persistent modes in descending order of priority, i.e., + // ascending order of their index in the ChannelUserModes list + sort.Slice(persistentModes, func(i, j int) bool { + index := func(modeChange modes.ModeChange) int { + for idx, mode := range modes.ChannelUserModes { + if modeChange.Mode == mode { + return idx + } + } + return len(modes.ChannelUserModes) + } + return index(persistentModes[i]) < index(persistentModes[j]) + }) + csNotice(rb, fmt.Sprintf(client.t("Channel %s has %d persistent modes set"), channelName, len(persistentModes))) + for _, modeChange := range persistentModes { + csNotice(rb, fmt.Sprintf(client.t("Account %s receives mode +%s"), modeChange.Arg, string(modeChange.Mode))) + } + return + } + + accountIsValid := false + change := modeChanges[0] + // Arg is the account name, casefold it here + change.Arg, _ = CasefoldName(change.Arg) + if change.Arg != "" { + _, err := server.accounts.LoadAccount(change.Arg) + accountIsValid = (err == nil) + } + if !accountIsValid { + csNotice(rb, client.t("Account does not exist")) + return + } + applied := channel.ApplyAccountToUmodeChange(change) + if applied { + csNotice(rb, fmt.Sprintf(client.t("Successfully set mode %s"), change.String())) + go server.channelRegistry.StoreChannel(channel, IncludeLists) + } 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/getters.go b/irc/getters.go index 1972f53d..f30378df 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -303,3 +303,37 @@ func (channel *Channel) Founder() string { defer channel.stateMutex.RUnlock() return channel.registeredFounder } + +func (channel *Channel) AccountToUmode() (result []modes.ModeChange) { + channel.stateMutex.RLock() + defer channel.stateMutex.RUnlock() + for account, mode := range channel.accountToUMode { + result = append(result, modes.ModeChange{ + Mode: mode, + Arg: account, + Op: modes.Add, + }) + } + + return +} + +func (channel *Channel) ApplyAccountToUmodeChange(change modes.ModeChange) (applied bool) { + channel.stateMutex.Lock() + defer channel.stateMutex.Unlock() + + current := channel.accountToUMode[change.Arg] + switch change.Op { + case modes.Add: + applied = (current != change.Mode) + if applied { + channel.accountToUMode[change.Arg] = change.Mode + } + case modes.Remove: + applied = (current == change.Mode) + if applied { + delete(channel.accountToUMode, change.Arg) + } + } + return +} diff --git a/irc/modes/modes.go b/irc/modes/modes.go index 287dbd44..d6270feb 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 @@ -392,14 +388,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]) From 7122fb180cfb877129dd12c4619794b914668630 Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Wed, 23 May 2018 16:14:23 -0400 Subject: [PATCH 2/3] add a test --- irc/modes/modes_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/irc/modes/modes_test.go b/irc/modes/modes_test.go index 8f20bdfc..f68c79a7 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() From d3815fbe61de12688143b408e2ac1a59b18f933d Mon Sep 17 00:00:00 2001 From: Shivaram Lingamneni Date: Fri, 25 May 2018 00:38:20 -0400 Subject: [PATCH 3/3] review fixes and updates --- irc/chanserv.go | 89 +++++++++++++++++++++++++---------------------- irc/errors.go | 1 + irc/getters.go | 34 ------------------ irc/modes.go | 76 ++++++++++++++++++++++++++++++++++++++++ irc/modes_test.go | 14 ++++++++ oragono.yaml | 1 + 6 files changed, 140 insertions(+), 75 deletions(-) diff --git a/irc/chanserv.go b/irc/chanserv.go index cd6cdd6f..3decf726 100644 --- a/irc/chanserv.go +++ b/irc/chanserv.go @@ -52,8 +52,7 @@ 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.`, - authRequired: true, + helpShort: `$bAMODE$b modifies persistent mode settings for channel members.`, }, } ) @@ -70,61 +69,69 @@ func csAmodeHandler(server *Server, client *Client, command, params string, rb * if channel == nil { csNotice(rb, client.t("Channel does not exist")) return - } - - clientAccount := client.Account() - if clientAccount == "" || clientAccount != channel.Founder() { - csNotice(rb, client.t("You must be the channel founder to use AMODE")) + } 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} } - if len(modeChanges) == 0 || modeChanges[0].Op == modes.List { - persistentModes := channel.AccountToUmode() - // sort the persistent modes in descending order of priority, i.e., - // ascending order of their index in the ChannelUserModes list - sort.Slice(persistentModes, func(i, j int) bool { - index := func(modeChange modes.ModeChange) int { - for idx, mode := range modes.ChannelUserModes { - if modeChange.Mode == mode { - return idx - } - } - return len(modes.ChannelUserModes) - } - return index(persistentModes[i]) < index(persistentModes[j]) - }) - csNotice(rb, fmt.Sprintf(client.t("Channel %s has %d persistent modes set"), channelName, len(persistentModes))) - for _, modeChange := range persistentModes { - csNotice(rb, fmt.Sprintf(client.t("Account %s receives mode +%s"), modeChange.Arg, string(modeChange.Mode))) - } - return - } - + // normalize and validate the account argument accountIsValid := false - change := modeChanges[0] - // Arg is the account name, casefold it here change.Arg, _ = CasefoldName(change.Arg) - if change.Arg != "" { - _, err := server.accounts.LoadAccount(change.Arg) - accountIsValid = (err == nil) + 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 } - applied := channel.ApplyAccountToUmodeChange(change) - if applied { - csNotice(rb, fmt.Sprintf(client.t("Successfully set mode %s"), change.String())) - go server.channelRegistry.StoreChannel(channel, IncludeLists) - } else { - csNotice(rb, client.t("Change was a no-op")) + + 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")) + } } } 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/getters.go b/irc/getters.go index f30378df..1972f53d 100644 --- a/irc/getters.go +++ b/irc/getters.go @@ -303,37 +303,3 @@ func (channel *Channel) Founder() string { defer channel.stateMutex.RUnlock() return channel.registeredFounder } - -func (channel *Channel) AccountToUmode() (result []modes.ModeChange) { - channel.stateMutex.RLock() - defer channel.stateMutex.RUnlock() - for account, mode := range channel.accountToUMode { - result = append(result, modes.ModeChange{ - Mode: mode, - Arg: account, - Op: modes.Add, - }) - } - - return -} - -func (channel *Channel) ApplyAccountToUmodeChange(change modes.ModeChange) (applied bool) { - channel.stateMutex.Lock() - defer channel.stateMutex.Unlock() - - current := channel.accountToUMode[change.Arg] - switch change.Op { - case modes.Add: - applied = (current != change.Mode) - if applied { - channel.accountToUMode[change.Arg] = change.Mode - } - case modes.Remove: - applied = (current == change.Mode) - if applied { - delete(channel.accountToUMode, change.Arg) - } - } - return -} 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_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 020d5226..815b52f8 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -283,6 +283,7 @@ oper-classes: - "unregister" - "samode" - "vhosts" + - "chanreg" # ircd operators opers: