Compare commits
2 Commits
765d35b399
...
dca7ed3387
Author | SHA1 | Date | |
---|---|---|---|
dca7ed3387 | |||
092bc50199 |
145
message.go
145
message.go
@ -1,12 +1,37 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags is an key-order-preserving, last-insert-wins slice
|
||||||
|
type Tags struct {
|
||||||
|
tags []Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tags) Insert(new Tag) {
|
||||||
|
for i := range t.tags {
|
||||||
|
if t.tags[i].Key == new.Key {
|
||||||
|
t.tags[i] = new
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.tags = append(t.tags, new)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tags) GetSlice() []Tag {
|
||||||
|
return t.tags
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Tags map[string]string
|
Tags []Tag
|
||||||
Source string
|
Source string
|
||||||
Command command
|
Command command
|
||||||
Parameters []string
|
Parameters []string
|
||||||
@ -32,8 +57,8 @@ func (m *Message) ParseMessage() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Message) parseTags() (map[string]string, error) {
|
func (m *Message) parseTags() ([]Tag, error) {
|
||||||
tags := make(map[string]string)
|
tags := &Tags{}
|
||||||
for {
|
for {
|
||||||
next := m.findNext("=", ";")
|
next := m.findNext("=", ";")
|
||||||
if next == -1 {
|
if next == -1 {
|
||||||
@ -42,17 +67,17 @@ func (m *Message) parseTags() (map[string]string, error) {
|
|||||||
key := m.Raw[m.parseIndex : m.parseIndex+next]
|
key := m.Raw[m.parseIndex : m.parseIndex+next]
|
||||||
m.parseIndex += next + 1
|
m.parseIndex += next + 1
|
||||||
eot := m.findNext(";", " ")
|
eot := m.findNext(";", " ")
|
||||||
tags[key] = m.Raw[m.parseIndex : m.parseIndex+eot]
|
tags.Insert(Tag{Key: key, Value: unescapeTag(m.Raw[m.parseIndex : m.parseIndex+eot])})
|
||||||
m.parseIndex += len(tags[key]) + 1
|
m.parseIndex += len(m.Raw[m.parseIndex:m.parseIndex+eot]) + 1
|
||||||
} else if m.Raw[m.parseIndex+next] == ';' {
|
} else if m.Raw[m.parseIndex+next] == ';' {
|
||||||
key := m.Raw[m.parseIndex : m.parseIndex+next]
|
key := m.Raw[m.parseIndex : m.parseIndex+next]
|
||||||
m.parseIndex += next + 1
|
m.parseIndex += next + 1
|
||||||
tags[key] = ""
|
tags.Insert(Tag{Key: key, Value: ""})
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tags, nil
|
return tags.GetSlice(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Message) parseSource() (string, error) {
|
func (m *Message) parseSource() (string, error) {
|
||||||
@ -69,9 +94,10 @@ func (m *Message) parseCommand() (command, error) {
|
|||||||
start := m.parseIndex
|
start := m.parseIndex
|
||||||
endofparse := strings.Index(m.Raw[m.parseIndex:], " ")
|
endofparse := strings.Index(m.Raw[m.parseIndex:], " ")
|
||||||
if endofparse == -1 {
|
if endofparse == -1 {
|
||||||
return "", fmt.Errorf("end of string encountered while parsing command")
|
m.parseIndex = len(m.Raw) // a command with no parameters
|
||||||
}
|
} else {
|
||||||
m.parseIndex += endofparse
|
m.parseIndex += endofparse
|
||||||
|
}
|
||||||
return command(m.Raw[start:m.parseIndex]), nil
|
return command(m.Raw[start:m.parseIndex]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,6 +157,103 @@ func (m *Message) findNext(bs ...string) int {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Message) ToBytes() []byte {
|
func unescapeTag(s string) string {
|
||||||
return nil
|
sb := strings.Builder{}
|
||||||
|
escaping := false
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if escaping {
|
||||||
|
if i+1 > len(s) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch s[i] {
|
||||||
|
case ':':
|
||||||
|
sb.WriteByte(';')
|
||||||
|
case '\\':
|
||||||
|
sb.WriteByte('\\')
|
||||||
|
case 's':
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
case 'r':
|
||||||
|
sb.WriteByte('\r')
|
||||||
|
case 'n':
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
default:
|
||||||
|
sb.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
escaping = false
|
||||||
|
} else if s[i] == '\\' {
|
||||||
|
if !escaping {
|
||||||
|
escaping = true
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeTag(s string) string {
|
||||||
|
sb := strings.Builder{}
|
||||||
|
for i := range s {
|
||||||
|
switch s[i] {
|
||||||
|
case ';':
|
||||||
|
sb.Write([]byte{'\\', ':'})
|
||||||
|
case ' ':
|
||||||
|
sb.Write([]byte{'\\', 's'})
|
||||||
|
case '\\':
|
||||||
|
sb.Write([]byte{'\\', '\\'})
|
||||||
|
case '\n':
|
||||||
|
sb.Write([]byte{'\\', 'n'})
|
||||||
|
case '\r':
|
||||||
|
sb.Write([]byte{'\\', 'r'})
|
||||||
|
default:
|
||||||
|
sb.WriteByte(s[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) Canonicalize() []byte {
|
||||||
|
sb := bytes.NewBuffer([]byte{})
|
||||||
|
if m.Tags != nil && len(m.Tags) > 0 {
|
||||||
|
sb.WriteByte('@')
|
||||||
|
for i := range m.Tags {
|
||||||
|
sb.WriteString(m.Tags[i].Key)
|
||||||
|
if m.Tags[i].Value != "" {
|
||||||
|
sb.WriteByte('=')
|
||||||
|
sb.WriteString(escapeTag(m.Tags[i].Value))
|
||||||
|
}
|
||||||
|
if i+1 != len(m.Tags) {
|
||||||
|
sb.WriteByte(';')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Source != "" {
|
||||||
|
sb.WriteByte(':')
|
||||||
|
sb.WriteString(m.Source)
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(string(m.Command))
|
||||||
|
|
||||||
|
if m.Parameters != nil && len(m.Parameters) > 0 {
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
for i := range m.Parameters {
|
||||||
|
if i+1 == len(m.Parameters) && (strings.Contains(m.Parameters[i], " ") || m.Parameters[i] == "" || m.Parameters[i][0] == ':') {
|
||||||
|
sb.WriteByte(':')
|
||||||
|
}
|
||||||
|
sb.WriteString(m.Parameters[i])
|
||||||
|
if i+1 != len(m.Parameters) {
|
||||||
|
sb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.Bytes()
|
||||||
}
|
}
|
||||||
|
212
message_test.go
212
message_test.go
@ -20,7 +20,7 @@ func TestParsing(t *testing.T) {
|
|||||||
{
|
{
|
||||||
input: `@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
input: `@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
||||||
output: &Message{
|
output: &Message{
|
||||||
Tags: map[string]string{"id": "234AB"},
|
Tags: []Tag{{"id", "234AB"}},
|
||||||
Source: "dan!d@localhost",
|
Source: "dan!d@localhost",
|
||||||
Command: PRIVMSG,
|
Command: PRIVMSG,
|
||||||
Parameters: []string{"#chan", "Hey what's up!"},
|
Parameters: []string{"#chan", "Hey what's up!"},
|
||||||
@ -28,19 +28,37 @@ func TestParsing(t *testing.T) {
|
|||||||
{
|
{
|
||||||
input: `@a=b;c=32;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
input: `@a=b;c=32;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
||||||
output: &Message{
|
output: &Message{
|
||||||
Tags: map[string]string{"a": "b", "c": "32", "k": "", "rt": "ql7"},
|
Tags: []Tag{{"a", "b"}, {"c", "32"}, {"k", ""}, {"rt", "ql7"}},
|
||||||
Source: "dan!d@localhost",
|
Source: "dan!d@localhost",
|
||||||
Command: PRIVMSG,
|
Command: PRIVMSG,
|
||||||
Parameters: []string{"#chan", "Hey what's up!"},
|
Parameters: []string{"#chan", "Hey what's up!"},
|
||||||
}},
|
}},
|
||||||
|
{
|
||||||
|
input: `@a=b\\and\nk;c=72\s45;d=gh\:764 CAP`,
|
||||||
|
output: &Message{
|
||||||
|
Tags: []Tag{{"a", "b\\and\nk"}, {"c", "72 45"}, {"d", `gh;764`}},
|
||||||
|
Command: CAP,
|
||||||
|
}},
|
||||||
{
|
{
|
||||||
input: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
input: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
||||||
output: &Message{
|
output: &Message{
|
||||||
Tags: map[string]string{"tag1": "value1", "tag2": "", "vendor1/tag3": "value2", "vendor2/tag4": ""},
|
Tags: []Tag{{"tag1", "value1"}, {"tag2", ""}, {"vendor1/tag3", "value2"}, {"vendor2/tag4", ""}},
|
||||||
Source: "dan!d@localhost",
|
Source: "dan!d@localhost",
|
||||||
Command: PRIVMSG,
|
Command: PRIVMSG,
|
||||||
Parameters: []string{"#chan", "Hey what's up!"},
|
Parameters: []string{"#chan", "Hey what's up!"},
|
||||||
}},
|
}},
|
||||||
|
{
|
||||||
|
input: ":src AWAY",
|
||||||
|
output: &Message{
|
||||||
|
Source: "src",
|
||||||
|
Command: AWAY,
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: ":src AWAY ",
|
||||||
|
output: &Message{
|
||||||
|
Source: "src",
|
||||||
|
Command: AWAY,
|
||||||
|
}},
|
||||||
{
|
{
|
||||||
input: `CAP REQ :sasl`,
|
input: `CAP REQ :sasl`,
|
||||||
output: &Message{
|
output: &Message{
|
||||||
@ -65,6 +83,13 @@ func TestParsing(t *testing.T) {
|
|||||||
Command: CAP,
|
Command: CAP,
|
||||||
Parameters: []string{"REQ", " asdf qwer"},
|
Parameters: []string{"REQ", " asdf qwer"},
|
||||||
}},
|
}},
|
||||||
|
{
|
||||||
|
input: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz",
|
||||||
|
output: &Message{
|
||||||
|
Source: "coolguy!ag@net\x035w\x03ork.admin",
|
||||||
|
Command: PRIVMSG,
|
||||||
|
Parameters: []string{"foo", "bar baz"},
|
||||||
|
}},
|
||||||
{
|
{
|
||||||
input: `:coolguy PRIVMSG bar :lol :) `,
|
input: `:coolguy PRIVMSG bar :lol :) `,
|
||||||
output: &Message{
|
output: &Message{
|
||||||
@ -86,6 +111,57 @@ func TestParsing(t *testing.T) {
|
|||||||
Command: MODE,
|
Command: MODE,
|
||||||
Parameters: []string{"#tckk", "+n"},
|
Parameters: []string{"#tckk", "+n"},
|
||||||
}},
|
}},
|
||||||
|
{
|
||||||
|
input: ":services.esper.net MODE #foo-bar +o foobar ",
|
||||||
|
output: &Message{
|
||||||
|
Source: "services.esper.net",
|
||||||
|
Command: MODE,
|
||||||
|
Parameters: []string{"#foo-bar", "+o", "foobar"},
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: `@tag1=value\\ntest COMMAND`,
|
||||||
|
output: &Message{
|
||||||
|
Tags: []Tag{{"tag1", `value\ntest`}},
|
||||||
|
Command: command("COMMAND"),
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: `@tag1=value\1 COMMAND`,
|
||||||
|
output: &Message{
|
||||||
|
Tags: []Tag{{"tag1", `value1`}},
|
||||||
|
Command: command("COMMAND"),
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: `@tag1=value\ COMMAND`,
|
||||||
|
output: &Message{
|
||||||
|
Tags: []Tag{{"tag1", `value`}},
|
||||||
|
Command: command("COMMAND"),
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: `@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND`,
|
||||||
|
output: &Message{
|
||||||
|
Tags: []Tag{{"tag1", "5"}, {"tag2", "3"}, {"tag3", "4"}},
|
||||||
|
Command: command("COMMAND"),
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: `@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND`,
|
||||||
|
output: &Message{
|
||||||
|
Tags: []Tag{{"tag1", "5"}, {"tag2", "3"}, {"tag3", "4"}, {"vendor/tag2", "8"}},
|
||||||
|
Command: command("COMMAND"),
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: `:SomeOp MODE #channel :+i`,
|
||||||
|
output: &Message{
|
||||||
|
Source: "SomeOp",
|
||||||
|
Command: MODE,
|
||||||
|
Parameters: []string{"#channel", "+i"},
|
||||||
|
}},
|
||||||
|
{
|
||||||
|
input: `:SomeOp MODE #channel +oo SomeUser :AnotherUser`,
|
||||||
|
output: &Message{
|
||||||
|
Source: "SomeOp",
|
||||||
|
Command: MODE,
|
||||||
|
Parameters: []string{"#channel", "+oo", "SomeUser", "AnotherUser"},
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
for _, tc := range testcases {
|
for _, tc := range testcases {
|
||||||
m := &Message{Raw: tc.input}
|
m := &Message{Raw: tc.input}
|
||||||
@ -94,6 +170,12 @@ func TestParsing(t *testing.T) {
|
|||||||
t.Logf("tags: actual: %v, expected: %v", m.Tags, tc.output.Tags)
|
t.Logf("tags: actual: %v, expected: %v", m.Tags, tc.output.Tags)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
|
for i := range tc.output.Tags {
|
||||||
|
if tc.output.Tags[i] != m.Tags[i] {
|
||||||
|
t.Logf("tags: actual: %+v, expected: %+v", m.Tags[i], tc.output.Tags[i])
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
if tc.output.Source != m.Source {
|
if tc.output.Source != m.Source {
|
||||||
t.Logf("source: actual: %v, expected: %v", m.Source, tc.output.Source)
|
t.Logf("source: actual: %v, expected: %v", m.Source, tc.output.Source)
|
||||||
t.Fail()
|
t.Fail()
|
||||||
@ -106,5 +188,129 @@ func TestParsing(t *testing.T) {
|
|||||||
t.Logf("parameters: actual: %v (%d), expected: %v (%d)", m.Parameters, len(m.Parameters), tc.output.Parameters, len(tc.output.Parameters))
|
t.Logf("parameters: actual: %v (%d), expected: %v (%d)", m.Parameters, len(m.Parameters), tc.output.Parameters, len(tc.output.Parameters))
|
||||||
t.Fail()
|
t.Fail()
|
||||||
}
|
}
|
||||||
|
for i := range tc.output.Parameters {
|
||||||
|
if tc.output.Parameters[i] != m.Parameters[i] {
|
||||||
|
t.Logf("parameters: actual: %v, expected: %v", m.Parameters[i], tc.output.Parameters[i])
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalization(t *testing.T) {
|
||||||
|
testcases := []struct {
|
||||||
|
input *Message
|
||||||
|
output string
|
||||||
|
}{
|
||||||
|
// these outputs are based on
|
||||||
|
// https://github.com/ircdocs/parser-tests/blob/master/tests/msg-split.yaml
|
||||||
|
// and https://modern.ircdocs.horse/#messages
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Source: "irc.example.com",
|
||||||
|
Command: CAP,
|
||||||
|
Parameters: []string{"LS", "*", "multi-prefix extended-join sasl"},
|
||||||
|
},
|
||||||
|
output: `:irc.example.com CAP LS * :multi-prefix extended-join sasl`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Tags: []Tag{{"id", "234AB"}},
|
||||||
|
Source: "dan!d@localhost",
|
||||||
|
Command: PRIVMSG,
|
||||||
|
Parameters: []string{"#chan", "Hey what's up!"},
|
||||||
|
},
|
||||||
|
output: `@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Tags: []Tag{{"a", "b"}, {"c", "32"}, {"k", ""}, {"rt", "ql7"}},
|
||||||
|
Source: "dan!d@localhost",
|
||||||
|
Command: PRIVMSG,
|
||||||
|
Parameters: []string{"#chan", "Hey what's up!"},
|
||||||
|
},
|
||||||
|
output: `@a=b;c=32;k;rt=ql7 :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Tags: []Tag{{"a", "b\\and\nk"}, {"c", "72 45"}, {"d", `gh;764`}},
|
||||||
|
Command: CAP,
|
||||||
|
},
|
||||||
|
output: `@a=b\\and\nk;c=72\s45;d=gh\:764 CAP`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Tags: []Tag{{"tag1", "value1"}, {"tag2", ""}, {"vendor1/tag3", "value2"}, {"vendor2/tag4", ""}},
|
||||||
|
Source: "dan!d@localhost",
|
||||||
|
Command: PRIVMSG,
|
||||||
|
Parameters: []string{"#chan", "Hey what's up!"},
|
||||||
|
},
|
||||||
|
output: `@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 :dan!d@localhost PRIVMSG #chan :Hey what's up!`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Source: "src",
|
||||||
|
Command: AWAY,
|
||||||
|
},
|
||||||
|
output: ":src AWAY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Command: CAP,
|
||||||
|
Parameters: []string{"REQ", "sasl"},
|
||||||
|
},
|
||||||
|
output: `CAP REQ sasl`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Command: CAP,
|
||||||
|
Parameters: []string{"REQ", ""},
|
||||||
|
},
|
||||||
|
output: `CAP REQ :`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Command: CAP,
|
||||||
|
Parameters: []string{"REQ", ":asdf"},
|
||||||
|
},
|
||||||
|
output: `CAP REQ ::asdf`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Command: CAP,
|
||||||
|
Parameters: []string{"REQ", " asdf qwer"},
|
||||||
|
},
|
||||||
|
output: `CAP REQ : asdf qwer`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Source: "coolguy",
|
||||||
|
Command: PRIVMSG,
|
||||||
|
Parameters: []string{"bar", "lol :) "},
|
||||||
|
},
|
||||||
|
output: `:coolguy PRIVMSG bar :lol :) `,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Source: "coolguy!ag@net\x035w\x03ork.admin",
|
||||||
|
Command: PRIVMSG,
|
||||||
|
Parameters: []string{"foo", "bar baz"},
|
||||||
|
},
|
||||||
|
output: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: &Message{
|
||||||
|
Tags: []Tag{{"tag1", `value\ntest`}},
|
||||||
|
Command: command("COMMAND"),
|
||||||
|
},
|
||||||
|
output: `@tag1=value\\ntest COMMAND`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testcases {
|
||||||
|
output := string(tc.input.Canonicalize())
|
||||||
|
if output != tc.output {
|
||||||
|
t.Logf("received '%s', expected '%s'", output, tc.output)
|
||||||
|
t.Fail()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user