diff --git a/default.yaml b/default.yaml index c1c811aa..86f51c1e 100644 --- a/default.yaml +++ b/default.yaml @@ -556,6 +556,10 @@ channels: # than this value will get an empty response to /LIST (a time period of 0 disables) list-delay: 0s + # INVITE to an invite-only channel expires after this amount of time + # (0 or omit for no expiration): + invite-expiration: 24h + # operator classes oper-classes: # local operator diff --git a/irc/channel.go b/irc/channel.go index 878d73c6..59a80a04 100644 --- a/irc/channel.go +++ b/irc/channel.go @@ -677,6 +677,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp chname := channel.name chcfname := channel.nameCasefolded founder := channel.registeredFounder + createdAt := channel.createdTime chkey := channel.key limit := channel.userLimit chcount := len(channel.members) @@ -695,7 +696,7 @@ func (channel *Channel) Join(client *Client, key string, isSajoin bool, rb *Resp // 3. people invited with INVITE can join hasPrivs := isSajoin || (founder != "" && founder == details.account) || (persistentMode != 0 && persistentMode != modes.Voice) || - client.CheckInvited(chcfname) + client.CheckInvited(chcfname, createdAt) if !hasPrivs { if limit != 0 && chcount >= limit { return errLimitExceeded @@ -1475,23 +1476,33 @@ func (channel *Channel) Kick(client *Client, target *Client, comment string, rb // Invite invites the given client to the channel, if the inviter can do so. func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuffer) { - chname := channel.Name() - if channel.flags.HasMode(modes.InviteOnly) && !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { - rb.Add(nil, inviter.server.name, ERR_CHANOPRIVSNEEDED, inviter.Nick(), chname, inviter.t("You're not a channel operator")) - return - } + channel.stateMutex.RLock() + chname := channel.name + chcfname := channel.nameCasefolded + createdAt := channel.createdTime + _, inviterPresent := channel.members[inviter] + _, inviteePresent := channel.members[invitee] + channel.stateMutex.RUnlock() - if !channel.hasClient(inviter) { + if !inviterPresent { rb.Add(nil, inviter.server.name, ERR_NOTONCHANNEL, inviter.Nick(), chname, inviter.t("You're not on that channel")) return } - if channel.hasClient(invitee) { + inviteOnly := channel.flags.HasMode(modes.InviteOnly) + if inviteOnly && !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { + rb.Add(nil, inviter.server.name, ERR_CHANOPRIVSNEEDED, inviter.Nick(), chname, inviter.t("You're not a channel operator")) + return + } + + if inviteePresent { rb.Add(nil, inviter.server.name, ERR_USERONCHANNEL, inviter.Nick(), invitee.Nick(), chname, inviter.t("User is already on that channel")) return } - invitee.Invite(channel.NameCasefolded()) + if inviteOnly { + invitee.Invite(chcfname, createdAt) + } for _, member := range channel.Members() { if member == inviter || member == invitee || !channel.ClientIsAtLeast(member, modes.Halfop) { @@ -1513,6 +1524,22 @@ func (channel *Channel) Invite(invitee *Client, inviter *Client, rb *ResponseBuf } } +// Uninvite rescinds a channel invitation, if the inviter can do so. +func (channel *Channel) Uninvite(invitee *Client, inviter *Client, rb *ResponseBuffer) { + if !channel.flags.HasMode(modes.InviteOnly) { + rb.Add(nil, channel.server.name, "FAIL", "UNINVITE", "NOT_INVITE_ONLY", channel.Name(), inviter.t("Channel is not invite-only")) + return + } + + if !channel.ClientIsAtLeast(inviter, modes.ChannelOperator) { + rb.Add(nil, channel.server.name, "FAIL", "UNINVITE", "NOT_PRIVED", channel.Name(), inviter.t("You're not a channel operator")) + return + } + + invitee.Uninvite(channel.NameCasefolded()) + rb.Add(nil, channel.server.name, "UNINVITE", invitee.Nick(), channel.Name()) +} + // returns who the client can "see" in the channel, respecting the auditorium mode func (channel *Channel) auditoriumFriends(client *Client) (friends []*Client) { channel.stateMutex.RLock() diff --git a/irc/client.go b/irc/client.go index c4920f37..693d14b8 100644 --- a/irc/client.go +++ b/irc/client.go @@ -84,7 +84,7 @@ type Client struct { destroyed bool modes modes.ModeSet hostname string - invitedTo utils.StringSet + invitedTo map[string]channelInvite isSTSOnly bool languages []string lastActive time.Time // last time they sent a command that wasn't PONG or similar @@ -1764,26 +1764,51 @@ func (client *Client) removeChannel(channel *Channel) { } } +type channelInvite struct { + channelCreatedAt time.Time + invitedAt time.Time +} + // Records that the client has been invited to join an invite-only channel -func (client *Client) Invite(casefoldedChannel string) { +func (client *Client) Invite(casefoldedChannel string, channelCreatedAt time.Time) { + now := time.Now().UTC() client.stateMutex.Lock() defer client.stateMutex.Unlock() if client.invitedTo == nil { - client.invitedTo = make(utils.StringSet) + client.invitedTo = make(map[string]channelInvite) } - client.invitedTo.Add(casefoldedChannel) + client.invitedTo[casefoldedChannel] = channelInvite{ + channelCreatedAt: channelCreatedAt, + invitedAt: now, + } + + return +} + +func (client *Client) Uninvite(casefoldedChannel string) { + client.stateMutex.Lock() + defer client.stateMutex.Unlock() + delete(client.invitedTo, casefoldedChannel) } // Checks that the client was invited to join a given channel -func (client *Client) CheckInvited(casefoldedChannel string) (invited bool) { +func (client *Client) CheckInvited(casefoldedChannel string, createdTime time.Time) (invited bool) { + config := client.server.Config() + expTime := time.Duration(config.Channels.InviteExpiration) + now := time.Now().UTC() + client.stateMutex.Lock() defer client.stateMutex.Unlock() - invited = client.invitedTo.Has(casefoldedChannel) - // joining an invited channel "uses up" your invite, so you can't rejoin on kick - delete(client.invitedTo, casefoldedChannel) + curInvite, ok := client.invitedTo[casefoldedChannel] + if ok { + // joining an invited channel "uses up" your invite, so you can't rejoin on kick + delete(client.invitedTo, casefoldedChannel) + } + invited = ok && (expTime == time.Duration(0) || now.Sub(curInvite.invitedAt) < expTime) && + createdTime.Equal(curInvite.channelCreatedAt) return } diff --git a/irc/commands.go b/irc/commands.go index c1ec6f41..cb81fd2f 100644 --- a/irc/commands.go +++ b/irc/commands.go @@ -324,6 +324,10 @@ func init() { minParams: 1, oper: true, }, + "UNINVITE": { + handler: inviteHandler, + minParams: 2, + }, "UNKLINE": { handler: unKLineHandler, minParams: 1, diff --git a/irc/config.go b/irc/config.go index 4b014935..58f5b8e2 100644 --- a/irc/config.go +++ b/irc/config.go @@ -577,7 +577,8 @@ type Config struct { OperatorOnly bool `yaml:"operator-only"` MaxChannelsPerAccount int `yaml:"max-channels-per-account"` } - ListDelay time.Duration `yaml:"list-delay"` + ListDelay time.Duration `yaml:"list-delay"` + InviteExpiration custime.Duration `yaml:"invite-expiration"` } OperClasses map[string]*OperClassConfig `yaml:"oper-classes"` diff --git a/irc/handlers.go b/irc/handlers.go index 8e7474b4..be3228f9 100644 --- a/irc/handlers.go +++ b/irc/handlers.go @@ -1113,7 +1113,9 @@ func infoHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Resp } // INVITE +// UNINVITE func inviteHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool { + invite := msg.Command == "INVITE" nickname := msg.Params[0] channelName := msg.Params[1] @@ -1129,7 +1131,12 @@ func inviteHandler(server *Server, client *Client, msg ircmsg.IrcMessage, rb *Re return false } - channel.Invite(target, client, rb) + if invite { + channel.Invite(target, client, rb) + } else { + channel.Uninvite(target, client, rb) + } + return false } diff --git a/irc/help.go b/irc/help.go index c867c31d..5fbd130b 100644 --- a/irc/help.go +++ b/irc/help.go @@ -541,6 +541,11 @@ For example: Used in connection registration, sets your username and realname to the given values (though your username may also be looked up with Ident).`, + }, + "uninvite": { + text: `UNINVITE + +UNINVITE rescinds a channel invitation sent for an invite-only channel.`, }, "users": { text: `USERS [parameters] diff --git a/traditional.yaml b/traditional.yaml index 94ad7e76..cf718911 100644 --- a/traditional.yaml +++ b/traditional.yaml @@ -528,6 +528,10 @@ channels: # than this value will get an empty response to /LIST (a time period of 0 disables) list-delay: 0s + # INVITE to an invite-only channel expires after this amount of time + # (0 or omit for no expiration): + invite-expiration: 24h + # operator classes oper-classes: # local operator