Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ba555c6a8
|
|||
|
54f65d8740
|
@@ -176,6 +176,18 @@ func (l *Listener) HandleEvent(msg []byte) (err error) {
|
||||
}
|
||||
return
|
||||
}
|
||||
// validate timestamp - reject events too far in the future (more than 1 hour)
|
||||
now := time.Now().Unix()
|
||||
if env.E.CreatedAt > now+3600 {
|
||||
if err = Ok.Invalid(
|
||||
l, env,
|
||||
"timestamp too far in the future",
|
||||
); chk.E(err) {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// verify the signature
|
||||
var ok bool
|
||||
if ok, err = env.Verify(); chk.T(err) {
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.20.4
|
||||
v0.21.0
|
||||
@@ -14,12 +14,15 @@ import (
|
||||
|
||||
// Client wraps a WebSocket connection to a relay for testing.
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
url string
|
||||
mu sync.Mutex
|
||||
subs map[string]chan []byte
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
conn *websocket.Conn
|
||||
url string
|
||||
mu sync.Mutex
|
||||
subs map[string]chan []byte
|
||||
complete map[string]bool // Track if subscription is complete (e.g., by ID)
|
||||
okCh chan []byte // Channel for OK messages
|
||||
countCh chan []byte // Channel for COUNT messages
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewClient creates a new test client connected to the relay.
|
||||
@@ -34,11 +37,14 @@ func NewClient(url string) (c *Client, err error) {
|
||||
return
|
||||
}
|
||||
c = &Client{
|
||||
conn: conn,
|
||||
url: url,
|
||||
subs: make(map[string]chan []byte),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
conn: conn,
|
||||
url: url,
|
||||
subs: make(map[string]chan []byte),
|
||||
complete: make(map[string]bool),
|
||||
okCh: make(chan []byte, 100),
|
||||
countCh: make(chan []byte, 100),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
go c.readLoop()
|
||||
return
|
||||
@@ -105,12 +111,32 @@ func (c *Client) readLoop() {
|
||||
if len(raw) >= 2 {
|
||||
if subID, ok := raw[1].(string); ok {
|
||||
if ch, exists := c.subs[subID]; exists {
|
||||
close(ch)
|
||||
// Send EOSE message to channel
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
// For complete subscriptions (by ID), close the channel after EOSE
|
||||
if c.complete[subID] {
|
||||
close(ch)
|
||||
delete(c.subs, subID)
|
||||
delete(c.complete, subID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case "OK":
|
||||
// OK messages are handled by WaitForOK
|
||||
// Route OK messages to okCh for WaitForOK
|
||||
select {
|
||||
case c.okCh <- msg:
|
||||
default:
|
||||
}
|
||||
case "COUNT":
|
||||
// Route COUNT messages to countCh for Count
|
||||
select {
|
||||
case c.countCh <- msg:
|
||||
default:
|
||||
}
|
||||
case "NOTICE":
|
||||
// Notice messages are logged
|
||||
case "CLOSED":
|
||||
@@ -132,6 +158,19 @@ func (c *Client) Subscribe(subID string, filters []interface{}) (ch chan []byte,
|
||||
c.mu.Lock()
|
||||
ch = make(chan []byte, 100)
|
||||
c.subs[subID] = ch
|
||||
// Check if subscription is complete (has 'ids' filter)
|
||||
isComplete := false
|
||||
for _, f := range filters {
|
||||
if fMap, ok := f.(map[string]interface{}); ok {
|
||||
if ids, exists := fMap["ids"]; exists {
|
||||
if idList, ok := ids.([]string); ok && len(idList) > 0 {
|
||||
isComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.complete[subID] = isComplete
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -140,8 +179,17 @@ func (c *Client) Subscribe(subID string, filters []interface{}) (ch chan []byte,
|
||||
func (c *Client) Unsubscribe(subID string) error {
|
||||
c.mu.Lock()
|
||||
if ch, exists := c.subs[subID]; exists {
|
||||
close(ch)
|
||||
// Channel might already be closed by EOSE, so use recover to handle gracefully
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
// Channel was already closed, ignore
|
||||
}
|
||||
}()
|
||||
close(ch)
|
||||
}()
|
||||
delete(c.subs, subID)
|
||||
delete(c.complete, subID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return c.Send([]interface{}{"CLOSE", subID})
|
||||
@@ -149,10 +197,7 @@ func (c *Client) Unsubscribe(subID string) error {
|
||||
|
||||
// Publish sends an EVENT message to the relay.
|
||||
func (c *Client) Publish(ev *event.E) (err error) {
|
||||
evJSON, err := json.Marshal(ev.Serialize())
|
||||
if err != nil {
|
||||
return errorf.E("failed to marshal event: %w", err)
|
||||
}
|
||||
evJSON := ev.Serialize()
|
||||
var evMap map[string]interface{}
|
||||
if err = json.Unmarshal(evJSON, &evMap); err != nil {
|
||||
return errorf.E("failed to unmarshal event: %w", err)
|
||||
@@ -169,21 +214,14 @@ func (c *Client) WaitForOK(eventID []byte, timeout time.Duration) (accepted bool
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, "", errorf.E("timeout waiting for OK response")
|
||||
default:
|
||||
}
|
||||
var msg []byte
|
||||
_, msg, err = c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return false, "", errorf.E("connection closed: %w", err)
|
||||
}
|
||||
var raw []interface{}
|
||||
if err = json.Unmarshal(msg, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(raw) < 3 {
|
||||
continue
|
||||
}
|
||||
if typ, ok := raw[0].(string); ok && typ == "OK" {
|
||||
case msg := <-c.okCh:
|
||||
var raw []interface{}
|
||||
if err = json.Unmarshal(msg, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(raw) < 3 {
|
||||
continue
|
||||
}
|
||||
if id, ok := raw[1].(string); ok && id == idStr {
|
||||
accepted, _ = raw[2].(bool)
|
||||
if len(raw) > 3 {
|
||||
@@ -208,23 +246,16 @@ func (c *Client) Count(filters []interface{}) (count int64, err error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, errorf.E("timeout waiting for COUNT response")
|
||||
default:
|
||||
}
|
||||
_, msg, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return 0, errorf.E("connection closed: %w", err)
|
||||
}
|
||||
var raw []interface{}
|
||||
if err = json.Unmarshal(msg, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(raw) >= 3 {
|
||||
if typ, ok := raw[0].(string); ok && typ == "COUNT" {
|
||||
case msg := <-c.countCh:
|
||||
var raw []interface{}
|
||||
if err = json.Unmarshal(msg, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(raw) >= 3 {
|
||||
if subID, ok := raw[1].(string); ok && subID == "count-sub" {
|
||||
if countObj, ok := raw[2].(map[string]interface{}); ok {
|
||||
if c, ok := countObj["count"].(float64); ok {
|
||||
return int64(c), nil
|
||||
}
|
||||
// COUNT response format: ["COUNT", "subscription-id", count, approximate?]
|
||||
if cnt, ok := raw[2].(float64); ok {
|
||||
return int64(cnt), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,12 +265,9 @@ func (c *Client) Count(filters []interface{}) (count int64, err error) {
|
||||
|
||||
// Auth sends an AUTH message with the signed event.
|
||||
func (c *Client) Auth(ev *event.E) error {
|
||||
evJSON, err := json.Marshal(ev.Serialize())
|
||||
if err != nil {
|
||||
return errorf.E("failed to marshal event: %w", err)
|
||||
}
|
||||
evJSON := ev.Serialize()
|
||||
var evMap map[string]interface{}
|
||||
if err = json.Unmarshal(evJSON, &evMap); err != nil {
|
||||
if err := json.Unmarshal(evJSON, &evMap); err != nil {
|
||||
return errorf.E("failed to unmarshal event: %w", err)
|
||||
}
|
||||
return c.Send([]interface{}{"AUTH", evMap})
|
||||
@@ -266,14 +294,27 @@ func (c *Client) GetEvents(subID string, filters []interface{}, timeout time.Dur
|
||||
if err = json.Unmarshal(msg, &raw); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(raw) >= 3 && raw[0] == "EVENT" {
|
||||
if evData, ok := raw[2].(map[string]interface{}); ok {
|
||||
evJSON, _ := json.Marshal(evData)
|
||||
ev := event.New()
|
||||
if _, err = ev.Unmarshal(evJSON); err == nil {
|
||||
events = append(events, ev)
|
||||
if len(raw) < 2 {
|
||||
continue
|
||||
}
|
||||
typ, ok := raw[0].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typ {
|
||||
case "EVENT":
|
||||
if len(raw) >= 3 {
|
||||
if evData, ok := raw[2].(map[string]interface{}); ok {
|
||||
evJSON, _ := json.Marshal(evData)
|
||||
ev := event.New()
|
||||
if _, err = ev.Unmarshal(evJSON); err == nil {
|
||||
events = append(events, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "EOSE":
|
||||
// End of stored events - return what we have
|
||||
return events, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ func CreateEphemeralEvent(signer *p256k.Signer, kindNum uint16, content string)
|
||||
func CreateDeleteEvent(signer *p256k.Signer, eventIDs [][]byte, reason string) (ev *event.E, err error) {
|
||||
tags := tag.NewS()
|
||||
for _, id := range eventIDs {
|
||||
tags.Append(tag.NewFromBytesSlice([]byte("e"), id))
|
||||
// e tags must contain hex-encoded event IDs
|
||||
tags.Append(tag.NewFromBytesSlice([]byte("e"), []byte(hex.Enc(id))))
|
||||
}
|
||||
if reason != "" {
|
||||
tags.Append(tag.NewFromBytesSlice([]byte("content"), []byte(reason)))
|
||||
|
||||
@@ -161,6 +161,175 @@ func (s *TestSuite) registerTests() {
|
||||
Required: true,
|
||||
Func: testSubscriptionClose,
|
||||
},
|
||||
// Filter tests
|
||||
{
|
||||
Name: "Since and until filters are inclusive",
|
||||
Required: true,
|
||||
Func: testSinceUntilAreInclusive,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Limit zero works",
|
||||
Required: true,
|
||||
Func: testLimitZero,
|
||||
},
|
||||
// Find tests
|
||||
{
|
||||
Name: "Events are ordered from newest to oldest",
|
||||
Required: true,
|
||||
Func: testEventsOrderedFromNewestToOldest,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Newest events are returned when filter is limited",
|
||||
Required: true,
|
||||
Func: testNewestEventsWhenLimited,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Finds by pubkey and kind",
|
||||
Required: true,
|
||||
Func: testFindByPubkeyAndKind,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Finds by pubkey and tags",
|
||||
Required: true,
|
||||
Func: testFindByPubkeyAndTags,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Finds by kind and tags",
|
||||
Required: true,
|
||||
Func: testFindByKindAndTags,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Finds by scrape",
|
||||
Required: true,
|
||||
Func: testFindByScrape,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
// Replaceable event tests
|
||||
{
|
||||
Name: "Replaces metadata",
|
||||
Required: true,
|
||||
Func: testReplacesMetadata,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Replaces contact list",
|
||||
Required: true,
|
||||
Func: testReplacesContactList,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Replaced events are still available by ID",
|
||||
Required: false,
|
||||
Func: testReplacedEventsStillAvailableByID,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Replaceable events replace older ones",
|
||||
Required: true,
|
||||
Func: testReplaceableEventRemovesPrevious,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Replaceable events rejected if a newer one exists",
|
||||
Required: true,
|
||||
Func: testReplaceableEventRejectedIfFuture,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Addressable events replace older ones",
|
||||
Required: true,
|
||||
Func: testAddressableEventRemovesPrevious,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Addressable events rejected if a newer one exists",
|
||||
Required: true,
|
||||
Func: testAddressableEventRejectedIfFuture,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
// Deletion tests
|
||||
{
|
||||
Name: "Deletes by a-tag address",
|
||||
Required: true,
|
||||
Func: testDeleteByAddr,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Delete by a-tag deletes older but not newer",
|
||||
Required: true,
|
||||
Func: testDeleteByAddrOnlyDeletesOlder,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Delete by a-tag is bound by a-tag",
|
||||
Required: true,
|
||||
Func: testDeleteByAddrIsBoundByTag,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
// Ephemeral tests
|
||||
{
|
||||
Name: "Ephemeral subscriptions work",
|
||||
Required: false,
|
||||
Func: testEphemeralSubscriptionsWork,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Persists ephemeral events",
|
||||
Required: false,
|
||||
Func: testPersistsEphemeralEvents,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
// EOSE tests
|
||||
{
|
||||
Name: "Supports EOSE",
|
||||
Required: true,
|
||||
Func: testSupportsEose,
|
||||
},
|
||||
{
|
||||
Name: "Closes complete subscriptions after EOSE",
|
||||
Required: false,
|
||||
Func: testClosesCompleteSubscriptionsAfterEose,
|
||||
},
|
||||
{
|
||||
Name: "Keeps open incomplete subscriptions after EOSE",
|
||||
Required: true,
|
||||
Func: testKeepsOpenIncompleteSubscriptionsAfterEose,
|
||||
},
|
||||
// JSON tests
|
||||
{
|
||||
Name: "Accepts events with empty tags",
|
||||
Required: false,
|
||||
Func: testAcceptsEventsWithEmptyTags,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
{
|
||||
Name: "Accepts NIP-01 JSON escape sequences",
|
||||
Required: true,
|
||||
Func: testAcceptsNip1JsonEscapeSequences,
|
||||
Dependencies: []string{"Publishes basic event"},
|
||||
},
|
||||
// Registration tests
|
||||
{
|
||||
Name: "Sends OK after EVENT",
|
||||
Required: true,
|
||||
Func: testSendsOkAfterEvent,
|
||||
},
|
||||
{
|
||||
Name: "Verifies event signatures",
|
||||
Required: true,
|
||||
Func: testVerifiesSignatures,
|
||||
},
|
||||
{
|
||||
Name: "Verifies event ID hashes",
|
||||
Required: true,
|
||||
Func: testVerifiesIdHashes,
|
||||
},
|
||||
}
|
||||
for _, tc := range allTests {
|
||||
s.AddTest(tc)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
102
relay_test.go
102
relay_test.go
@@ -2,10 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -34,7 +33,8 @@ func TestRelay(t *testing.T) {
|
||||
relayURL = testRelayURL
|
||||
} else {
|
||||
// Start local relay for testing
|
||||
if relay, err = startTestRelay(); err != nil {
|
||||
var port int
|
||||
if relay, port, err = startTestRelay(); err != nil {
|
||||
t.Fatalf("Failed to start test relay: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
@@ -42,20 +42,22 @@ func TestRelay(t *testing.T) {
|
||||
t.Logf("Error stopping relay: %v", stopErr)
|
||||
}
|
||||
}()
|
||||
port := relayPort
|
||||
if port == 0 {
|
||||
port = 3334 // Default port
|
||||
}
|
||||
relayURL = fmt.Sprintf("ws://127.0.0.1:%d", port)
|
||||
// Wait for relay to be ready
|
||||
time.Sleep(2 * time.Second)
|
||||
t.Logf("Waiting for relay to be ready at %s...", relayURL)
|
||||
// Wait for relay to be ready - try connecting to verify it's up
|
||||
if err = waitForRelay(relayURL, 10*time.Second); err != nil {
|
||||
t.Fatalf("Relay not ready after timeout: %v", err)
|
||||
}
|
||||
t.Logf("Relay is ready at %s", relayURL)
|
||||
}
|
||||
|
||||
// Create test suite
|
||||
t.Logf("Creating test suite for %s...", relayURL)
|
||||
suite, err := relaytester.NewTestSuite(relayURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test suite: %v", err)
|
||||
}
|
||||
t.Logf("Test suite created, running tests...")
|
||||
|
||||
// Run tests
|
||||
var results []relaytester.TestResult
|
||||
@@ -92,20 +94,43 @@ func TestRelay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func startTestRelay() (relay *run.Relay, err error) {
|
||||
func startTestRelay() (relay *run.Relay, port int, err error) {
|
||||
cfg := &config.C{
|
||||
AppName: "ORLY-TEST",
|
||||
DataDir: relayDataDir,
|
||||
Listen: "127.0.0.1",
|
||||
Port: relayPort,
|
||||
LogLevel: "warn",
|
||||
DBLogLevel: "warn",
|
||||
ACLMode: "none",
|
||||
AppName: "ORLY-TEST",
|
||||
DataDir: relayDataDir,
|
||||
Listen: "127.0.0.1",
|
||||
Port: 0, // Always use random port, unless overridden via -port flag
|
||||
HealthPort: 0,
|
||||
EnableShutdown: false,
|
||||
LogLevel: "warn",
|
||||
DBLogLevel: "warn",
|
||||
DBBlockCacheMB: 512,
|
||||
DBIndexCacheMB: 256,
|
||||
LogToStdout: false,
|
||||
PprofHTTP: false,
|
||||
ACLMode: "none",
|
||||
AuthRequired: false,
|
||||
AuthToWrite: false,
|
||||
SubscriptionEnabled: false,
|
||||
MonthlyPriceSats: 6000,
|
||||
FollowListFrequency: time.Hour,
|
||||
WebDisableEmbedded: false,
|
||||
SprocketEnabled: false,
|
||||
SpiderMode: "none",
|
||||
PolicyEnabled: false,
|
||||
}
|
||||
|
||||
// Set default port if not specified
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 3334
|
||||
// Use explicitly set port if provided via flag, otherwise find an available port
|
||||
if relayPort > 0 {
|
||||
cfg.Port = relayPort
|
||||
} else {
|
||||
var listener net.Listener
|
||||
if listener, err = net.Listen("tcp", "127.0.0.1:0"); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to find available port: %w", err)
|
||||
}
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
cfg.Port = addr.Port
|
||||
listener.Close()
|
||||
}
|
||||
|
||||
// Set default data dir if not specified
|
||||
@@ -125,21 +150,34 @@ func startTestRelay() (relay *run.Relay, err error) {
|
||||
|
||||
// Start relay
|
||||
if relay, err = run.Start(cfg, opts); err != nil {
|
||||
return nil, fmt.Errorf("failed to start relay: %w", err)
|
||||
return nil, 0, fmt.Errorf("failed to start relay: %w", err)
|
||||
}
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigChan
|
||||
if relay != nil {
|
||||
relay.Stop()
|
||||
}
|
||||
os.Exit(0)
|
||||
}()
|
||||
return relay, cfg.Port, nil
|
||||
}
|
||||
|
||||
return relay, nil
|
||||
// waitForRelay waits for the relay to be ready by attempting to connect
|
||||
func waitForRelay(url string, timeout time.Duration) error {
|
||||
// Extract host:port from ws:// URL
|
||||
addr := url
|
||||
if len(url) > 7 && url[:5] == "ws://" {
|
||||
addr = url[5:]
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
attempts := 0
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
attempts++
|
||||
if attempts%10 == 0 {
|
||||
// Log every 10th attempt (every second)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for relay at %s after %d attempts", url, attempts)
|
||||
}
|
||||
|
||||
func outputResults(results []relaytester.TestResult, t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user