mirror of
https://github.com/jwetzell/osc-go.git
synced 2026-08-31 19:19:01 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3055cb9a5 | |||
| 6012e98efd | |||
| a30ffb1311 | |||
| f878255c7f | |||
| 7a5ef4c56b | |||
| 113cb622e8 | |||
| 1994949841 | |||
| fe499f0c21 | |||
| 2278ad7a1c | |||
| d4a56fcd11 | |||
| e8626ab230 | |||
| 58672c9ab3 | |||
| 8b8129b2e2 | |||
| f7850ce70e | |||
| 82e9c7ed24 | |||
| 9f34a57141 | |||
| 6aadaedb5d |
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- goarch: "386"
|
- goarch: "386"
|
||||||
goos: darwin
|
goos: darwin
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: wangyoucao577/go-release-action@v1
|
- uses: wangyoucao577/go-release-action@v1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- goarch: "386"
|
- goarch: "386"
|
||||||
goos: darwin
|
goos: darwin
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: wangyoucao577/go-release-action@v1
|
- uses: wangyoucao577/go-release-action@v1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- goarch: "386"
|
- goarch: "386"
|
||||||
goos: darwin
|
goos: darwin
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- uses: wangyoucao577/go-release-action@v1
|
- uses: wangyoucao577/go-release-action@v1
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test -v -coverprofile=coverage.txt .
|
run: go test -v -coverprofile=coverage.txt .
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
files: coverage.txt
|
files: coverage.txt
|
||||||
@@ -45,54 +45,43 @@ func BundleFromBytes(bytes []byte) (*OSCBundle, []byte, error) {
|
|||||||
return nil, bytesAfterBundleHeader, errors.New("OSC Bundle must start with #bundle string")
|
return nil, bytesAfterBundleHeader, errors.New("OSC Bundle must start with #bundle string")
|
||||||
}
|
}
|
||||||
|
|
||||||
timeTag, bytesAfterTimeTag, err := readOSCTimeTag(bytesAfterBundleHeader)
|
timeTag, bytesAfterTimeTag, _ := readOSCTimeTag(bytesAfterBundleHeader)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, bytesAfterBundleHeader, err
|
|
||||||
}
|
|
||||||
|
|
||||||
bundleContents := []OSCPacket{}
|
bundleContents := []OSCPacket{}
|
||||||
|
|
||||||
endOfBundle := false
|
|
||||||
|
|
||||||
remainingBytes := bytesAfterTimeTag
|
remainingBytes := bytesAfterTimeTag
|
||||||
|
|
||||||
for !endOfBundle {
|
contentSize, bytesAfterContentSize, _ := readOSCInt32(remainingBytes)
|
||||||
contentSize, bytesAfterContentSize, err := readOSCInt32(remainingBytes)
|
|
||||||
|
|
||||||
|
remainingBytes = bytesAfterContentSize
|
||||||
|
|
||||||
|
if contentSize <= 0 {
|
||||||
|
return nil, remainingBytes, errors.New("bundle content size must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(remainingBytes) < int(contentSize) {
|
||||||
|
return nil, remainingBytes, errors.New("bundle doesn't have enough bytes for the content size it specifies")
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleContentBytes := remainingBytes[0:contentSize]
|
||||||
|
|
||||||
|
switch bundleContentBytes[0] {
|
||||||
|
case 35: // #
|
||||||
|
content, _, err := BundleFromBytes(bundleContentBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, remainingBytes, err
|
return nil, remainingBytes, err
|
||||||
}
|
}
|
||||||
|
bundleContents = append(bundleContents, content)
|
||||||
remainingBytes = bytesAfterContentSize
|
case 47: // /
|
||||||
|
content, err := MessageFromBytes(bundleContentBytes)
|
||||||
if len(remainingBytes) < int(contentSize) {
|
if err != nil {
|
||||||
return nil, remainingBytes, errors.New("bundle doesn't have enough bytes for the content size it specifies")
|
return nil, remainingBytes, err
|
||||||
}
|
}
|
||||||
|
bundleContents = append(bundleContents, content)
|
||||||
bundleContentBytes := remainingBytes[0:contentSize]
|
default:
|
||||||
|
return nil, remainingBytes, errors.New("bundle contents does not look a bundle or message")
|
||||||
if bundleContentBytes[0] == 35 {
|
|
||||||
content, _, err := BundleFromBytes(bundleContentBytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, remainingBytes, err
|
|
||||||
}
|
|
||||||
bundleContents = append(bundleContents, content)
|
|
||||||
} else if bundleContentBytes[0] == 47 {
|
|
||||||
content, err := MessageFromBytes(bundleContentBytes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, remainingBytes, err
|
|
||||||
}
|
|
||||||
bundleContents = append(bundleContents, content)
|
|
||||||
} else {
|
|
||||||
return nil, remainingBytes, errors.New("bundle contents does not look a bundle or message")
|
|
||||||
}
|
|
||||||
remainingBytes = bytesAfterContentSize[contentSize:]
|
|
||||||
if len(remainingBytes) == 0 {
|
|
||||||
endOfBundle = true
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
remainingBytes = bytesAfterContentSize[contentSize:]
|
||||||
|
|
||||||
return &OSCBundle{
|
return &OSCBundle{
|
||||||
TimeTag: timeTag,
|
TimeTag: timeTag,
|
||||||
|
|||||||
@@ -194,6 +194,24 @@ func TestBadOSCBundleDecoding(t *testing.T) {
|
|||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
|
||||||
errorString: "bundle contents does not look a bundle or message",
|
errorString: "bundle contents does not look a bundle or message",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "bundle with bad bundle inside",
|
||||||
|
bytes: []byte{35, 98, 117, 110, 100, 108, 101, 0, // #bundle
|
||||||
|
0, 0, 0, 32, 0, 0, 0, 0, // time tag
|
||||||
|
0, 0, 0, 19, // content size
|
||||||
|
35, 98, 117, 110, 100, 108, 101, 0, // #bundle
|
||||||
|
0, 0, 0, 64, 0, 0, 0, 0, // time tag
|
||||||
|
0, 0, 0},
|
||||||
|
errorString: "OSC Bundle has to be at least 20 bytes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bundle with bad message inside",
|
||||||
|
bytes: []byte{35, 98, 117, 110, 100, 108, 101, 0, // #bundle
|
||||||
|
0, 0, 0, 32, 0, 0, 0, 0, // time tag
|
||||||
|
0, 0, 0, 5, // content size
|
||||||
|
47, 104, 101, 108, 108, 111},
|
||||||
|
errorString: "OSC string must be null-terminated",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
@@ -210,3 +228,31 @@ func TestBadOSCBundleDecoding(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FuzzBundleFromBytes(f *testing.F) {
|
||||||
|
seedBytes := [][]byte{
|
||||||
|
{},
|
||||||
|
{35, 98, 117, 110, 100, 108, 101, 0, 0, 0, 0,
|
||||||
|
32, 0, 0, 0, 0, 0, 0, 0, 32, 47, 111,
|
||||||
|
115, 99, 105, 108, 108, 97, 116, 111, 114, 47, 52,
|
||||||
|
47, 102, 114, 101, 113, 117, 101, 110, 99, 121, 0,
|
||||||
|
44, 102, 0, 0, 67, 220, 0, 0},
|
||||||
|
{35, 98, 117, 110, 100, 108, 101, 0, // #bundle
|
||||||
|
0, 0, 0, 32, 0, 0, 0, 0, // time tag
|
||||||
|
0, 0, 0, 52, // content size
|
||||||
|
35, 98, 117, 110, 100, 108, 101, 0, // #bundle
|
||||||
|
0, 0, 0, 64, 0, 0, 0, 0, // time tag
|
||||||
|
0, 0, 0, 32, // content size
|
||||||
|
47, 111, 115, 99, 105, 108, 108, 97, 116, 111, 114, 47, 52,
|
||||||
|
47, 102, 114, 101, 113, 117, 101, 110, 99, 121, 0,
|
||||||
|
44, 102, 0, 0, 67, 220, 0, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, seed := range seedBytes {
|
||||||
|
f.Add(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
_, _, _ = BundleFromBytes(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,11 +141,12 @@ func slipEncode(bytes []byte) []byte {
|
|||||||
var encodedBytes = []byte{END}
|
var encodedBytes = []byte{END}
|
||||||
|
|
||||||
for _, byteToEncode := range bytes {
|
for _, byteToEncode := range bytes {
|
||||||
if byteToEncode == END {
|
switch byteToEncode {
|
||||||
|
case END:
|
||||||
encodedBytes = append(encodedBytes, ESC, ESC_END)
|
encodedBytes = append(encodedBytes, ESC, ESC_END)
|
||||||
} else if byteToEncode == ESC {
|
case ESC:
|
||||||
encodedBytes = append(encodedBytes, ESC, ESC_ESC)
|
encodedBytes = append(encodedBytes, ESC, ESC_ESC)
|
||||||
} else {
|
default:
|
||||||
encodedBytes = append(encodedBytes, byteToEncode)
|
encodedBytes = append(encodedBytes, byteToEncode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,8 +179,5 @@ func makeMsg(address string, args []string, types []string, slip bool) {
|
|||||||
if slip {
|
if slip {
|
||||||
oscMessageBuffer = slipEncode(oscMessageBuffer)
|
oscMessageBuffer = slipEncode(oscMessageBuffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO write buffer to stdout
|
|
||||||
os.Stdout.Write(oscMessageBuffer)
|
os.Stdout.Write(oscMessageBuffer)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,9 +120,10 @@ func (s *SLIP) decode(bytes []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if escapeNext {
|
if escapeNext {
|
||||||
if packetByte == ESC_END {
|
switch packetByte {
|
||||||
|
case ESC_END:
|
||||||
s.pendingBytes = append(s.pendingBytes, END)
|
s.pendingBytes = append(s.pendingBytes, END)
|
||||||
} else if packetByte == ESC_ESC {
|
case ESC_ESC:
|
||||||
s.pendingBytes = append(s.pendingBytes, ESC)
|
s.pendingBytes = append(s.pendingBytes, ESC)
|
||||||
}
|
}
|
||||||
escapeNext = false
|
escapeNext = false
|
||||||
@@ -203,23 +204,23 @@ func handleBundle(bundle *osc.OSCBundle, format string) {
|
|||||||
|
|
||||||
func listenUDP(netAddress string, format string) {
|
func listenUDP(netAddress string, format string) {
|
||||||
|
|
||||||
s, err := net.ResolveUDPAddr("udp4", netAddress)
|
laddr, err := net.ResolveUDPAddr("udp4", netAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
connection, err := net.ListenUDP("udp4", s)
|
conn, err := net.ListenUDP("udp4", laddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer connection.Close()
|
defer conn.Close()
|
||||||
buffer := make([]byte, 1024)
|
buffer := make([]byte, 1024)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
bytesRead, _, err := connection.ReadFromUDP(buffer)
|
bytesRead, _, err := conn.ReadFromUDP(buffer)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
@@ -169,11 +169,12 @@ func slipEncode(bytes []byte) []byte {
|
|||||||
var encodedBytes = []byte{END}
|
var encodedBytes = []byte{END}
|
||||||
|
|
||||||
for _, byteToEncode := range bytes {
|
for _, byteToEncode := range bytes {
|
||||||
if byteToEncode == END {
|
switch byteToEncode {
|
||||||
|
case END:
|
||||||
encodedBytes = append(encodedBytes, ESC, ESC_END)
|
encodedBytes = append(encodedBytes, ESC, ESC_END)
|
||||||
} else if byteToEncode == ESC {
|
case ESC:
|
||||||
encodedBytes = append(encodedBytes, ESC, ESC_ESC)
|
encodedBytes = append(encodedBytes, ESC, ESC_ESC)
|
||||||
} else {
|
default:
|
||||||
encodedBytes = append(encodedBytes, byteToEncode)
|
encodedBytes = append(encodedBytes, byteToEncode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ module github.com/jwetzell/osc-go
|
|||||||
|
|
||||||
go 1.25.1
|
go 1.25.1
|
||||||
|
|
||||||
require github.com/urfave/cli/v3 v3.8.0
|
require github.com/urfave/cli/v3 v3.10.1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
|
github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY=
|
||||||
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -511,3 +511,46 @@ func TestBadOSCMessageDecoding(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BenchmarkMessageToBytes(b *testing.B) {
|
||||||
|
message := &OSCMessage{
|
||||||
|
Address: "/hello",
|
||||||
|
Args: []OSCArg{
|
||||||
|
{Type: "i", Value: 35},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := message.ToBytes()
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to encode properly: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkMessageFromBytes(b *testing.B) {
|
||||||
|
bytes := []byte{47, 104, 101, 108, 108, 111, 0, 0, 44, 105, 0, 0, 0, 0, 0, 35}
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
_, err := MessageFromBytes(bytes)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("failed to decode properly: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzMessageFromBytes(f *testing.F) {
|
||||||
|
seedBytes := [][]byte{
|
||||||
|
{},
|
||||||
|
{47, 104, 101, 108, 108, 111, 0, 0},
|
||||||
|
{47, 104, 101, 108, 108, 111, 0, 0, 44, 105, 0, 0, 0, 0, 0, 35},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, seed := range seedBytes {
|
||||||
|
f.Add(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
_, _ = MessageFromBytes(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ func readOSCArg(bytes []byte, oscType string) (OSCArg, []byte, error) {
|
|||||||
oscArg := OSCArg{}
|
oscArg := OSCArg{}
|
||||||
oscArg.Type = oscType
|
oscArg.Type = oscType
|
||||||
|
|
||||||
remainingBytes := []byte{}
|
var remainingBytes []byte
|
||||||
//TODO(jwetzell): add error handling
|
//TODO(jwetzell): add error handling
|
||||||
switch oscType {
|
switch oscType {
|
||||||
case "s":
|
case "s":
|
||||||
|
|||||||
+14
-2
@@ -292,14 +292,26 @@ func TestBadPacketFromBytes(t *testing.T) {
|
|||||||
bytes []byte
|
bytes []byte
|
||||||
errorString string
|
errorString string
|
||||||
}{
|
}{
|
||||||
{name: "empty bytes",
|
{
|
||||||
|
name: "empty bytes",
|
||||||
bytes: []byte{},
|
bytes: []byte{},
|
||||||
errorString: "cannot create OSC Packet from empty byte array",
|
errorString: "cannot create OSC Packet from empty byte array",
|
||||||
},
|
},
|
||||||
{name: "packet that does not start with / or #",
|
{
|
||||||
|
name: "packet that does not start with / or #",
|
||||||
bytes: []byte{0, 1, 2, 3},
|
bytes: []byte{0, 1, 2, 3},
|
||||||
errorString: "OSC Packet must start with # for bundle or / for message",
|
errorString: "OSC Packet must start with # for bundle or / for message",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "bundle with not enough bytes",
|
||||||
|
bytes: []byte{35, 98, 117, 110, 100, 108, 101, 0},
|
||||||
|
errorString: "OSC Bundle has to be at least 20 bytes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "message without null terminated address",
|
||||||
|
bytes: []byte{47, 104, 101, 108, 108, 111},
|
||||||
|
errorString: "OSC string must be null-terminated",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, testCase := range testCases {
|
for _, testCase := range testCases {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
[]byte("#bundle\x0000000000\x00\x00\x00\x00")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
[]byte("/00\x00,b0\x00\xff000")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
[]byte("/00\x00,b0\x00\x00\x00\x00\x010")
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
go test fuzz v1
|
||||||
|
[]byte("/00\x00,d\x0000000")
|
||||||
Reference in New Issue
Block a user