mirror of
https://github.com/ergochat/ergo.git
synced 2024-11-10 22:19:31 +01:00
Merge
This commit is contained in:
commit
3680a3fe9a
78
irc/batch.go
Normal file
78
irc/batch.go
Normal file
@ -0,0 +1,78 @@
|
||||
// Copyright (c) 2017 Daniel Oaks <daniel@danieloaks.net>
|
||||
// released under the MIT license
|
||||
|
||||
package irc
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/goshuirc/irc-go/ircmsg"
|
||||
"github.com/oragono/oragono/irc/caps"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxBatchID is the maximum ID the batch counter can get to before it rotates.
|
||||
//
|
||||
// Batch IDs are made up of the current unix timestamp plus a rolling int ID that's
|
||||
// incremented for every new batch. It's an alright solution and will work unless we get
|
||||
// more than maxId batches per nanosecond. Later on when we have S2S linking, the batch
|
||||
// ID will also contain the server ID to ensure they stay unique.
|
||||
maxBatchID uint64 = 60000
|
||||
)
|
||||
|
||||
// BatchManager helps generate new batches and new batch IDs.
|
||||
type BatchManager struct {
|
||||
idCounter uint64
|
||||
}
|
||||
|
||||
// NewBatchManager returns a new Manager.
|
||||
func NewBatchManager() *BatchManager {
|
||||
return &BatchManager{}
|
||||
}
|
||||
|
||||
// NewID returns a new batch ID that should be unique.
|
||||
func (bm *BatchManager) NewID() string {
|
||||
bm.idCounter++
|
||||
if maxBatchID < bm.idCounter {
|
||||
bm.idCounter = 0
|
||||
}
|
||||
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36) + strconv.FormatUint(bm.idCounter, 36)
|
||||
}
|
||||
|
||||
// Batch represents an IRCv3 batch.
|
||||
type Batch struct {
|
||||
ID string
|
||||
Type string
|
||||
Params []string
|
||||
}
|
||||
|
||||
// New returns a new batch.
|
||||
func (bm *BatchManager) New(batchType string, params ...string) *Batch {
|
||||
newBatch := Batch{
|
||||
ID: bm.NewID(),
|
||||
Type: batchType,
|
||||
Params: params,
|
||||
}
|
||||
|
||||
return &newBatch
|
||||
}
|
||||
|
||||
// Start sends the batch start message to this client
|
||||
func (b *Batch) Start(client *Client, tags *map[string]ircmsg.TagValue) {
|
||||
if client.capabilities.Has(caps.Batch) {
|
||||
params := []string{"+" + b.ID, b.Type}
|
||||
for _, param := range b.Params {
|
||||
params = append(params, param)
|
||||
}
|
||||
client.Send(tags, client.server.name, "BATCH", params...)
|
||||
}
|
||||
}
|
||||
|
||||
// End sends the batch end message to this client
|
||||
func (b *Batch) End(client *Client) {
|
||||
if client.capabilities.Has(caps.Batch) {
|
||||
client.Send(nil, client.server.name, "BATCH", "-"+b.ID)
|
||||
}
|
||||
}
|
@ -744,7 +744,7 @@ func (client *Client) SendFromClient(msgid string, from *Client, tags *map[strin
|
||||
|
||||
var (
|
||||
// these are all the output commands that MUST have their last param be a trailing.
|
||||
// this is needed because silly clients like to treat trailing as separate from the
|
||||
// this is needed because dumb clients like to treat trailing params separately from the
|
||||
// other params in messages.
|
||||
commandsThatMustUseTrailing = map[string]bool{
|
||||
"PRIVMSG": true,
|
||||
@ -755,6 +755,47 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// SendRawMessage sends a raw message to the client.
|
||||
func (client *Client) SendRawMessage(message ircmsg.IrcMessage) error {
|
||||
// use dumb hack to force the last param to be a trailing param if required
|
||||
var usedTrailingHack bool
|
||||
if commandsThatMustUseTrailing[strings.ToUpper(message.Command)] && len(message.Params) > 0 {
|
||||
lastParam := message.Params[len(message.Params)-1]
|
||||
// to force trailing, we ensure the final param contains a space
|
||||
if !strings.Contains(lastParam, " ") {
|
||||
message.Params[len(message.Params)-1] = lastParam + " "
|
||||
usedTrailingHack = true
|
||||
}
|
||||
}
|
||||
|
||||
// assemble message
|
||||
maxlenTags, maxlenRest := client.maxlens()
|
||||
line, err := message.LineMaxLen(maxlenTags, maxlenRest)
|
||||
if err != nil {
|
||||
// try not to fail quietly - especially useful when running tests, as a note to dig deeper
|
||||
// log.Println("Error assembling message:")
|
||||
// spew.Dump(message)
|
||||
// debug.PrintStack()
|
||||
|
||||
message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending")
|
||||
line, _ := message.Line()
|
||||
|
||||
// if we used the trailing hack, we need to strip the final space we appended earlier on
|
||||
if usedTrailingHack {
|
||||
line = line[:len(line)-3] + "\r\n"
|
||||
}
|
||||
|
||||
client.socket.Write(line)
|
||||
return err
|
||||
}
|
||||
|
||||
client.server.logger.Debug("useroutput", client.nick, " ->", strings.TrimRight(line, "\r\n"))
|
||||
|
||||
client.socket.Write(line)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send sends an IRC line to the client.
|
||||
func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) error {
|
||||
// attach server-time
|
||||
@ -767,41 +808,9 @@ func (client *Client) Send(tags *map[string]ircmsg.TagValue, prefix string, comm
|
||||
}
|
||||
}
|
||||
|
||||
// force trailing, if message requires it
|
||||
var usedTrailingHack bool
|
||||
if commandsThatMustUseTrailing[strings.ToUpper(command)] && len(params) > 0 {
|
||||
lastParam := params[len(params)-1]
|
||||
// to force trailing, we ensure the final param contains a space
|
||||
if !strings.Contains(lastParam, " ") {
|
||||
params[len(params)-1] = lastParam + " "
|
||||
usedTrailingHack = true
|
||||
}
|
||||
}
|
||||
|
||||
// send out the message
|
||||
message := ircmsg.MakeMessage(tags, prefix, command, params...)
|
||||
maxlenTags, maxlenRest := client.maxlens()
|
||||
line, err := message.LineMaxLen(maxlenTags, maxlenRest)
|
||||
if err != nil {
|
||||
// try not to fail quietly - especially useful when running tests, as a note to dig deeper
|
||||
// log.Println("Error assembling message:")
|
||||
// spew.Dump(message)
|
||||
// debug.PrintStack()
|
||||
|
||||
message = ircmsg.MakeMessage(nil, client.server.name, ERR_UNKNOWNERROR, "*", "Error assembling message for sending")
|
||||
line, _ := message.Line()
|
||||
client.socket.Write(line)
|
||||
return err
|
||||
}
|
||||
|
||||
// is we used the trailing hack, we need to strip the final space we appended earlier
|
||||
if usedTrailingHack {
|
||||
line = line[:len(line)-3] + "\r\n"
|
||||
}
|
||||
|
||||
client.server.logger.Debug("useroutput", client.nick, " ->", strings.TrimRight(line, "\r\n"))
|
||||
|
||||
client.socket.Write(line)
|
||||
client.SendRawMessage(message)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@ import (
|
||||
|
||||
// Command represents a command accepted from a client.
|
||||
type Command struct {
|
||||
handler func(server *Server, client *Client, msg ircmsg.IrcMessage) bool
|
||||
handler func(server *Server, client *Client, msg ircmsg.IrcMessage, rb *ResponseBuffer) bool
|
||||
oper bool
|
||||
usablePreReg bool
|
||||
leaveClientActive bool // if true, leaves the client active time alone. reversed because we can't default a struct element to True
|
||||
@ -45,7 +45,12 @@ func (cmd *Command) Run(server *Server, client *Client, msg ircmsg.IrcMessage) b
|
||||
if !cmd.leaveClientIdle {
|
||||
client.Touch()
|
||||
}
|
||||
exiting := cmd.handler(server, client, msg)
|
||||
rb := NewResponseBuffer(client)
|
||||
rb.Label = GetLabel(msg)
|
||||
|
||||
exiting := cmd.handler(server, client, msg, rb)
|
||||
|
||||
rb.Send()
|
||||
|
||||
// after each command, see if we can send registration to the client
|
||||
if !client.registered {
|
||||
|
@ -2544,8 +2544,12 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool {
|
||||
masksString = msg.Params[0]
|
||||
}
|
||||
|
||||
rb := NewResponseBuffer(client)
|
||||
rb.Label = GetLabel(msg)
|
||||
|
||||
if len(strings.TrimSpace(masksString)) < 1 {
|
||||
client.Send(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("No masks given"))
|
||||
rb.Add(nil, server.name, ERR_UNKNOWNERROR, client.nick, msg.Command, client.t("No masks given"))
|
||||
rb.Send()
|
||||
return false
|
||||
}
|
||||
|
||||
@ -2554,16 +2558,16 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool {
|
||||
for _, mask := range masks {
|
||||
casefoldedMask, err := Casefold(mask)
|
||||
if err != nil {
|
||||
client.Send(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, client.t("No such nick"))
|
||||
rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, client.t("No such nick"))
|
||||
continue
|
||||
}
|
||||
matches := server.clients.FindAll(casefoldedMask)
|
||||
if len(matches) == 0 {
|
||||
client.Send(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, client.t("No such nick"))
|
||||
rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, mask, client.t("No such nick"))
|
||||
continue
|
||||
}
|
||||
for mclient := range matches {
|
||||
client.getWhoisOf(mclient)
|
||||
client.getWhoisOf(mclient, rb)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2571,13 +2575,14 @@ func whoisHandler(server *Server, client *Client, msg ircmsg.IrcMessage) bool {
|
||||
casefoldedMask, err := Casefold(strings.Split(masksString, ",")[0])
|
||||
mclient := server.clients.Get(casefoldedMask)
|
||||
if err != nil || mclient == nil {
|
||||
client.Send(nil, client.server.name, ERR_NOSUCHNICK, client.nick, masksString, client.t("No such nick"))
|
||||
rb.Add(nil, client.server.name, ERR_NOSUCHNICK, client.nick, masksString, client.t("No such nick"))
|
||||
// fall through, ENDOFWHOIS is always sent
|
||||
} else {
|
||||
client.getWhoisOf(mclient)
|
||||
client.getWhoisOf(mclient, rb)
|
||||
}
|
||||
}
|
||||
client.Send(nil, server.name, RPL_ENDOFWHOIS, client.nick, masksString, client.t("End of /WHOIS list"))
|
||||
rb.Add(nil, server.name, RPL_ENDOFWHOIS, client.nick, masksString, client.t("End of /WHOIS list"))
|
||||
rb.Send()
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -214,7 +214,7 @@ func (channel *Channel) ApplyChannelModeChanges(client *Client, isSamode bool, c
|
||||
if !hasPrivs(change) {
|
||||
if !alreadySentPrivError {
|
||||
alreadySentPrivError = true
|
||||
client.Send(nil, client.server.name, ERR_CHANOPRIVSNEEDED, channel.name, client.t("You're not a channel operator"))
|
||||
rb.Add(nil, client.server.name, ERR_CHANOPRIVSNEEDED, channel.name, client.t("You're not a channel operator"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
120
irc/responsebuffer.go
Normal file
120
irc/responsebuffer.go
Normal file
@ -0,0 +1,120 @@
|
||||
// Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
|
||||
// released under the MIT license
|
||||
|
||||
package irc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/goshuirc/irc-go/ircmsg"
|
||||
"github.com/oragono/oragono/irc/caps"
|
||||
)
|
||||
|
||||
// ResponseBuffer - put simply - buffers messages and then outputs them to a given client.
|
||||
//
|
||||
// Using a ResponseBuffer lets you really easily implement labeled-response, since the
|
||||
// buffer will silently create a batch if required and label the outgoing messages as
|
||||
// necessary (or leave it off and simply tag the outgoing message).
|
||||
type ResponseBuffer struct {
|
||||
Label string
|
||||
target *Client
|
||||
messages []ircmsg.IrcMessage
|
||||
}
|
||||
|
||||
// GetLabel returns the label from the given message.
|
||||
func GetLabel(msg ircmsg.IrcMessage) string {
|
||||
return msg.Tags["label"].Value
|
||||
}
|
||||
|
||||
// NewResponseBuffer returns a new ResponseBuffer.
|
||||
func NewResponseBuffer(target *Client) *ResponseBuffer {
|
||||
return &ResponseBuffer{
|
||||
target: target,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a standard new message to our queue.
|
||||
func (rb *ResponseBuffer) Add(tags *map[string]ircmsg.TagValue, prefix string, command string, params ...string) {
|
||||
message := ircmsg.MakeMessage(tags, prefix, command, params...)
|
||||
|
||||
rb.messages = append(rb.messages, message)
|
||||
}
|
||||
|
||||
// AddFromClient adds a new message from a specific client to our queue.
|
||||
func (rb *ResponseBuffer) AddFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, params ...string) {
|
||||
// attach account-tag
|
||||
if rb.target.capabilities.Has(caps.AccountTag) && from.account != &NoAccount {
|
||||
if tags == nil {
|
||||
tags = ircmsg.MakeTags("account", from.account.Name)
|
||||
} else {
|
||||
(*tags)["account"] = ircmsg.MakeTagValue(from.account.Name)
|
||||
}
|
||||
}
|
||||
// attach message-id
|
||||
if len(msgid) > 0 && rb.target.capabilities.Has(caps.MessageTags) {
|
||||
if tags == nil {
|
||||
tags = ircmsg.MakeTags("draft/msgid", msgid)
|
||||
} else {
|
||||
(*tags)["draft/msgid"] = ircmsg.MakeTagValue(msgid)
|
||||
}
|
||||
}
|
||||
|
||||
rb.Add(tags, from.nickMaskString, command, params...)
|
||||
}
|
||||
|
||||
// AddSplitMessageFromClient adds a new split message from a specific client to our queue.
|
||||
func (rb *ResponseBuffer) AddSplitMessageFromClient(msgid string, from *Client, tags *map[string]ircmsg.TagValue, command string, target string, message SplitMessage) {
|
||||
if rb.target.capabilities.Has(caps.MaxLine) {
|
||||
rb.AddFromClient(msgid, from, tags, command, target, message.ForMaxLine)
|
||||
} else {
|
||||
for _, str := range message.For512 {
|
||||
rb.AddFromClient(msgid, from, tags, command, target, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends the message to our target client.
|
||||
func (rb *ResponseBuffer) Send() error {
|
||||
// make batch and all if required
|
||||
var batch *Batch
|
||||
useLabel := rb.target.capabilities.Has(caps.LabeledResponse) && rb.Label != ""
|
||||
if useLabel && 1 < len(rb.messages) && rb.target.capabilities.Has(caps.Batch) {
|
||||
batch = rb.target.server.batches.New("draft/labeled-response")
|
||||
}
|
||||
|
||||
// if label but no batch, add label to first message
|
||||
if useLabel && batch == nil {
|
||||
message := rb.messages[0]
|
||||
message.Tags["label"] = ircmsg.MakeTagValue(rb.Label)
|
||||
rb.messages[0] = message
|
||||
}
|
||||
|
||||
// start batch if required
|
||||
if batch != nil {
|
||||
batch.Start(rb.target, ircmsg.MakeTags("label", rb.Label))
|
||||
}
|
||||
|
||||
// send each message out
|
||||
for _, message := range rb.messages {
|
||||
// attach server-time if needed
|
||||
if rb.target.capabilities.Has(caps.ServerTime) {
|
||||
t := time.Now().UTC().Format("2006-01-02T15:04:05.999Z")
|
||||
message.Tags["time"] = ircmsg.MakeTagValue(t)
|
||||
}
|
||||
|
||||
// attach batch ID
|
||||
if batch != nil {
|
||||
message.Tags["batch"] = ircmsg.MakeTagValue(batch.ID)
|
||||
}
|
||||
|
||||
// send message out
|
||||
rb.target.SendRawMessage(message)
|
||||
}
|
||||
|
||||
// end batch if required
|
||||
if batch != nil {
|
||||
batch.End(rb.target)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -49,7 +49,7 @@ var (
|
||||
|
||||
// SupportedCapabilities are the caps we advertise.
|
||||
// MaxLine, SASL and STS are set during server startup.
|
||||
SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.Languages, caps.MessageTags, caps.MultiPrefix, caps.Rename, caps.Resume, caps.ServerTime, caps.UserhostInNames)
|
||||
SupportedCapabilities = caps.NewSet(caps.AccountTag, caps.AccountNotify, caps.AwayNotify, caps.Batch, caps.CapNotify, caps.ChgHost, caps.EchoMessage, caps.ExtendedJoin, caps.InviteNotify, caps.LabeledResponse, caps.Languages, caps.MessageTags, caps.MultiPrefix, caps.Rename, caps.Resume, caps.ServerTime, caps.UserhostInNames)
|
||||
|
||||
// CapValues are the actual values we advertise to v3.2 clients.
|
||||
// actual values are set during server startup.
|
||||
@ -90,6 +90,7 @@ type Server struct {
|
||||
accountAuthenticationEnabled bool
|
||||
accountRegistration *AccountRegistration
|
||||
accounts map[string]*ClientAccount
|
||||
batches *BatchManager
|
||||
channelRegistrationEnabled bool
|
||||
channels *ChannelManager
|
||||
channelRegistry *ChannelRegistry
|
||||
@ -150,6 +151,7 @@ func NewServer(config *Config, logger *logger.Manager) (*Server, error) {
|
||||
// initialize data structures
|
||||
server := &Server{
|
||||
accounts: make(map[string]*ClientAccount),
|
||||
batches: NewBatchManager(),
|
||||
channels: NewChannelManager(),
|
||||
clients: NewClientManager(),
|
||||
connectionLimiter: connection_limits.NewLimiter(),
|
||||
@ -603,31 +605,31 @@ func (client *Client) WhoisChannelsNames(target *Client) []string {
|
||||
return chstrs
|
||||
}
|
||||
|
||||
func (client *Client) getWhoisOf(target *Client) {
|
||||
func (client *Client) getWhoisOf(target *Client, rb *ResponseBuffer) {
|
||||
target.stateMutex.RLock()
|
||||
defer target.stateMutex.RUnlock()
|
||||
|
||||
client.Send(nil, client.server.name, RPL_WHOISUSER, client.nick, target.nick, target.username, target.hostname, "*", target.realname)
|
||||
rb.Add(nil, client.server.name, RPL_WHOISUSER, client.nick, target.nick, target.username, target.hostname, "*", target.realname)
|
||||
|
||||
whoischannels := client.WhoisChannelsNames(target)
|
||||
if whoischannels != nil {
|
||||
client.Send(nil, client.server.name, RPL_WHOISCHANNELS, client.nick, target.nick, strings.Join(whoischannels, " "))
|
||||
rb.Add(nil, client.server.name, RPL_WHOISCHANNELS, client.nick, target.nick, strings.Join(whoischannels, " "))
|
||||
}
|
||||
if target.class != nil {
|
||||
client.Send(nil, client.server.name, RPL_WHOISOPERATOR, client.nick, target.nick, target.whoisLine)
|
||||
rb.Add(nil, client.server.name, RPL_WHOISOPERATOR, client.nick, target.nick, target.whoisLine)
|
||||
}
|
||||
if client.flags[modes.Operator] || client == target {
|
||||
client.Send(nil, client.server.name, RPL_WHOISACTUALLY, client.nick, target.nick, fmt.Sprintf("%s@%s", target.username, utils.LookupHostname(target.IPString())), target.IPString(), client.t("Actual user@host, Actual IP"))
|
||||
rb.Add(nil, client.server.name, RPL_WHOISACTUALLY, client.nick, target.nick, fmt.Sprintf("%s@%s", target.username, utils.LookupHostname(target.IPString())), target.IPString(), client.t("Actual user@host, Actual IP"))
|
||||
}
|
||||
if target.flags[modes.TLS] {
|
||||
client.Send(nil, client.server.name, RPL_WHOISSECURE, client.nick, target.nick, client.t("is using a secure connection"))
|
||||
rb.Add(nil, client.server.name, RPL_WHOISSECURE, client.nick, target.nick, client.t("is using a secure connection"))
|
||||
}
|
||||
accountName := target.AccountName()
|
||||
if accountName != "" {
|
||||
client.Send(nil, client.server.name, RPL_WHOISACCOUNT, client.nick, accountName, client.t("is logged in as"))
|
||||
rb.Add(nil, client.server.name, RPL_WHOISACCOUNT, client.nick, accountName, client.t("is logged in as"))
|
||||
}
|
||||
if target.flags[modes.Bot] {
|
||||
client.Send(nil, client.server.name, RPL_WHOISBOT, client.nick, target.nick, ircfmt.Unescape(fmt.Sprintf(client.t("is a $bBot$b on %s"), client.server.networkName)))
|
||||
rb.Add(nil, client.server.name, RPL_WHOISBOT, client.nick, target.nick, ircfmt.Unescape(fmt.Sprintf(client.t("is a $bBot$b on %s"), client.server.networkName)))
|
||||
}
|
||||
|
||||
if 0 < len(target.languages) {
|
||||
@ -636,13 +638,13 @@ func (client *Client) getWhoisOf(target *Client) {
|
||||
params = append(params, str)
|
||||
}
|
||||
params = append(params, client.t("can speak these languages"))
|
||||
client.Send(nil, client.server.name, RPL_WHOISLANGUAGE, params...)
|
||||
rb.Add(nil, client.server.name, RPL_WHOISLANGUAGE, params...)
|
||||
}
|
||||
|
||||
if target.certfp != "" && (client.flags[modes.Operator] || client == target) {
|
||||
client.Send(nil, client.server.name, RPL_WHOISCERTFP, client.nick, target.nick, fmt.Sprintf(client.t("has client certificate fingerprint %s"), target.certfp))
|
||||
rb.Add(nil, client.server.name, RPL_WHOISCERTFP, client.nick, target.nick, fmt.Sprintf(client.t("has client certificate fingerprint %s"), target.certfp))
|
||||
}
|
||||
client.Send(nil, client.server.name, RPL_WHOISIDLE, client.nick, target.nick, strconv.FormatUint(target.IdleSeconds(), 10), strconv.FormatInt(target.SignonTime(), 10), client.t("seconds idle, signon time"))
|
||||
rb.Add(nil, client.server.name, RPL_WHOISIDLE, client.nick, target.nick, strconv.FormatUint(target.IdleSeconds(), 10), strconv.FormatInt(target.SignonTime(), 10), client.t("seconds idle, signon time"))
|
||||
}
|
||||
|
||||
// rplWhoReply returns the WHO reply between one user and another channel/user.
|
||||
|
Loading…
Reference in New Issue
Block a user