rework ws responses to user, send events to user via ws
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
package WsEventType
|
||||||
|
|
||||||
|
type WsEventType uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Authentication WsEventType = iota
|
||||||
|
DirectMessage
|
||||||
|
ConnectionCreated
|
||||||
|
ConnectionDeleted
|
||||||
|
ConnectionElevated
|
||||||
|
)
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package WsMessageFrom
|
|
||||||
|
|
||||||
type WsMessageToUserFrom uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
Server WsMessageToUserFrom = iota
|
|
||||||
DirectMessage
|
|
||||||
Group
|
|
||||||
)
|
|
||||||
+60
-23
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-socket/Enums/ConnectionState"
|
"go-socket/Enums/ConnectionState"
|
||||||
|
"go-socket/Enums/WsEventType"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
@@ -64,7 +65,10 @@ func HttpHandleDm(response http.ResponseWriter, request *http.Request) {
|
|||||||
Receiver: conn.Id,
|
Receiver: conn.Id,
|
||||||
}
|
}
|
||||||
|
|
||||||
WsMessageSendToUser(target, message)
|
WsSendMessageCloseIfTimeout(target, WsEventMessage{
|
||||||
|
Type: WsEventType.DirectMessage,
|
||||||
|
Event: message,
|
||||||
|
})
|
||||||
|
|
||||||
err = DbMessageSave(ctx, message)
|
err = DbMessageSave(ctx, message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,6 +205,11 @@ func HttpHandleUserNewConnection(response http.ResponseWriter, request *http.Req
|
|||||||
}
|
}
|
||||||
CacheAddConnection(requestor, recipient, connection)
|
CacheAddConnection(requestor, recipient, connection)
|
||||||
|
|
||||||
|
WsSendMessageCloseIfTimeout(recipient, WsEventMessage{
|
||||||
|
Type: WsEventType.ConnectionCreated,
|
||||||
|
Event: connection,
|
||||||
|
})
|
||||||
|
|
||||||
response.WriteHeader(http.StatusCreated)
|
response.WriteHeader(http.StatusCreated)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -256,6 +265,10 @@ func HttpHandleUserDeleteConnection(response http.ResponseWriter, request *http.
|
|||||||
}
|
}
|
||||||
|
|
||||||
CacheDeleteConnection(user, user2, connectionId)
|
CacheDeleteConnection(user, user2, connectionId)
|
||||||
|
WsSendMessageCloseIfTimeout(user2, WsEventMessage{
|
||||||
|
Type: WsEventType.ConnectionDeleted,
|
||||||
|
Event: connectionId,
|
||||||
|
})
|
||||||
|
|
||||||
response.WriteHeader(http.StatusAccepted)
|
response.WriteHeader(http.StatusAccepted)
|
||||||
}
|
}
|
||||||
@@ -281,29 +294,53 @@ func HttpHandleUserElevateConnection(response http.ResponseWriter, request *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn.RecipientId != user.Id {
|
|
||||||
http.Error(response, "invalid connectionid", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch conn.State {
|
|
||||||
case ConnectionState.Stranger:
|
|
||||||
conn.State = ConnectionState.Friend
|
|
||||||
break
|
|
||||||
case ConnectionState.GroupFellow:
|
|
||||||
conn.State = ConnectionState.Stranger
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
http.Error(response, "cannot elevate further", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = DbConnectionUpdateState(ctx, conn)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(response, "internal server error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.WriteHeader(http.StatusAccepted)
|
response.WriteHeader(http.StatusAccepted)
|
||||||
|
|
||||||
|
// TODO change to != "" after user id via uuid
|
||||||
|
if conn.UserWantingToElevate != 0 && conn.UserWantingToElevate != user.Id {
|
||||||
|
switch conn.State {
|
||||||
|
case ConnectionState.Stranger:
|
||||||
|
conn.State = ConnectionState.Friend
|
||||||
|
break
|
||||||
|
case ConnectionState.GroupFellow:
|
||||||
|
conn.State = ConnectionState.Stranger
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
http.Error(response, "cannot elevate further", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DbConnectionUpdateState(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(response, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Write([]byte("elevated"))
|
||||||
|
|
||||||
|
var user2 *User
|
||||||
|
if conn.RequestorId == user.Id {
|
||||||
|
user2, err = GetUserById(ctx, conn.RecipientId)
|
||||||
|
} else {
|
||||||
|
user2, err = GetUserById(ctx, conn.RequestorId)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(response, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WsSendMessageCloseIfTimeout(user2, WsEventMessage{
|
||||||
|
Type: WsEventType.ConnectionElevated,
|
||||||
|
Event: ConnectionElevationData{
|
||||||
|
Id: connectionId,
|
||||||
|
NewState: conn.State,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.UserWantingToElevate = user.Id
|
||||||
|
response.Write([]byte("waiting for second user to elevate"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func HttpHandleUserGetConnections(response http.ResponseWriter, request *http.Request) {
|
func HttpHandleUserGetConnections(response http.ResponseWriter, request *http.Request) {
|
||||||
|
|||||||
+1
-1
@@ -338,7 +338,7 @@ func HttpHandleGroupMessage(response http.ResponseWriter, request *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WsSendToGroup(group, user, content)
|
err = WsSendToGroupAsUser(group, user, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(response, err.Error(), http.StatusBadRequest)
|
http.Error(response, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
+2
-2
@@ -53,13 +53,13 @@ func HttpHandleUserNewToken(response http.ResponseWriter, request *http.Request)
|
|||||||
|
|
||||||
token, err := TokenCreate(user.Id)
|
token, err := TokenCreate(user.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(response, "internal server error2", http.StatusInternalServerError)
|
http.Error(response, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json, err := json2.Marshal(LoginReturn{Token: token, UserId: user.Id})
|
json, err := json2.Marshal(LoginReturn{Token: token, UserId: user.Id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(response, "internal server error3", http.StatusInternalServerError)
|
http.Error(response, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@
|
|||||||
<button onclick="showForm('mod-user-appearance')">POST /mod/user/appearence</button>
|
<button onclick="showForm('mod-user-appearance')">POST /mod/user/appearence</button>
|
||||||
<button onclick="showForm('mod-user-about')">POST /mod/user/about</button>
|
<button onclick="showForm('mod-user-about')">POST /mod/user/about</button>
|
||||||
<button onclick="showForm('accept-connection')">POST /mod/connection/accept</button>
|
<button onclick="showForm('accept-connection')">POST /mod/connection/accept</button>
|
||||||
<button onclick="showForm('delete-connection')" class="warn">POST /mod/connection/delete</button>
|
|
||||||
<button onclick="showForm('add-users')">POST /mod/group/addusers</button>
|
<button onclick="showForm('add-users')">POST /mod/group/addusers</button>
|
||||||
<button onclick="showForm('remove-users')">POST /mod/group/removeusers</button>
|
<button onclick="showForm('remove-users')">POST /mod/group/removeusers</button>
|
||||||
<button onclick="showForm('group-color')">POST /mod/group/color</button>
|
<button onclick="showForm('group-color')">POST /mod/group/color</button>
|
||||||
@@ -81,6 +80,7 @@
|
|||||||
<button onclick="showForm('get-groups')">POST /get/groups</button>
|
<button onclick="showForm('get-groups')">POST /get/groups</button>
|
||||||
<button onclick="showForm('get-connections')">POST /get/connections</button>
|
<button onclick="showForm('get-connections')">POST /get/connections</button>
|
||||||
<button onclick="showForm('get-members')">POST /get/group/members</button>
|
<button onclick="showForm('get-members')">POST /get/group/members</button>
|
||||||
|
<button onclick="showForm('del-user')" class="warn">POST /del/user</button>
|
||||||
<button onclick="showForm('del-group')" class="warn">POST /del/group</button>
|
<button onclick="showForm('del-group')" class="warn">POST /del/group</button>
|
||||||
<button onclick="showForm('del-connection')" class="warn">POST /del/connection</button>
|
<button onclick="showForm('del-connection')" class="warn">POST /del/connection</button>
|
||||||
<button onclick="showForm('get-connection-messages')">POST /get/connection/messages</button>
|
<button onclick="showForm('get-connection-messages')">POST /get/connection/messages</button>
|
||||||
@@ -164,14 +164,6 @@
|
|||||||
],
|
],
|
||||||
submit: () => httpPost('/mod/connection/accept', { token:'ca-token', connectionid:'ca-connectionid' })
|
submit: () => httpPost('/mod/connection/accept', { token:'ca-token', connectionid:'ca-connectionid' })
|
||||||
},
|
},
|
||||||
'delete-connection': {
|
|
||||||
title: 'POST /mod/connection/delete — delete connection',
|
|
||||||
fields: [
|
|
||||||
{ id: 'cd-token', label: 'token', ph: '' },
|
|
||||||
{ id: 'cd-connectionid', label: 'connectionid', ph: 'UUID' },
|
|
||||||
],
|
|
||||||
submit: () => httpPost('/mod/connection/delete', { token:'cd-token', connectionid:'cd-connectionid' })
|
|
||||||
},
|
|
||||||
'add-users': {
|
'add-users': {
|
||||||
title: 'POST /mod/group/addusers — add users (owner only)',
|
title: 'POST /mod/group/addusers — add users (owner only)',
|
||||||
fields: [
|
fields: [
|
||||||
@@ -230,6 +222,13 @@
|
|||||||
],
|
],
|
||||||
submit: () => httpPost('/get/group/members', { token:'gm-token', group:'gm-group' })
|
submit: () => httpPost('/get/group/members', { token:'gm-token', group:'gm-group' })
|
||||||
},
|
},
|
||||||
|
'del-user': {
|
||||||
|
title: 'POST /del/user — delete own account',
|
||||||
|
fields: [
|
||||||
|
{ id: 'du-token', label: 'token', ph: '' },
|
||||||
|
],
|
||||||
|
submit: () => httpPost('/del/user', { token:'du-token' })
|
||||||
|
},
|
||||||
'del-group': {
|
'del-group': {
|
||||||
title: 'POST /del/group — delete group (owner only)',
|
title: 'POST /del/group — delete group (owner only)',
|
||||||
fields: [
|
fields: [
|
||||||
|
|||||||
+31
-14
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-socket/Enums/ConnectionState"
|
"go-socket/Enums/ConnectionState"
|
||||||
|
"go-socket/Enums/WsEventType"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -24,15 +25,16 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Connection struct {
|
type Connection struct {
|
||||||
Mu sync.RWMutex `json:"-"`
|
Mu sync.RWMutex `json:"-"`
|
||||||
Id uuid.UUID `json:"id"`
|
Id uuid.UUID `json:"id"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
MessagesBuff [MaxDirectMsgCache]*Message `json:"-"`
|
MessagesBuff [MaxDirectMsgCache]*Message `json:"-"`
|
||||||
NextBuffIdx uint32 `json:"-"`
|
NextBuffIdx uint32 `json:"-"`
|
||||||
HaveOverflowed bool `json:"-"`
|
RequestorId uint32 `json:"requestorId"`
|
||||||
RequestorId uint32 `json:"requestorId"`
|
RecipientId uint32 `json:"recipientId"`
|
||||||
RecipientId uint32 `json:"recipientId"`
|
UserWantingToElevate uint32 `json:"userWantingToElevate"` // TODO add to database
|
||||||
State ConnectionState.ConnectionState `json:"state"`
|
HaveOverflowed bool `json:"-"`
|
||||||
|
State ConnectionState.ConnectionState `json:"state"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *Connection) AddMessageToBuff(message *Message) {
|
func (conn *Connection) AddMessageToBuff(message *Message) {
|
||||||
@@ -62,12 +64,17 @@ func (conn *Connection) GetSortedMessagesBuff() (*[MaxDirectMsgCache]*Message, u
|
|||||||
return sorted, MaxDirectMsgCache
|
return sorted, MaxDirectMsgCache
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConnectionElevationData struct {
|
||||||
|
Id uuid.UUID `json:"id"`
|
||||||
|
NewState ConnectionState.ConnectionState `json:"newState"`
|
||||||
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Id uuid.UUID `json:"id"`
|
Id uuid.UUID `json:"id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Sender uint32 `json:"sender"`
|
Sender uint32 `json:"sender"`
|
||||||
Receiver uuid.UUID `json:"receiver"`
|
Receiver uuid.UUID `json:"receiver"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Group struct {
|
type Group struct {
|
||||||
@@ -85,3 +92,13 @@ type LoginReturn struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
UserId uint32 `json:"userId"`
|
UserId uint32 `json:"userId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WsEventMessage struct {
|
||||||
|
Type WsEventType.WsEventType `json:"type"`
|
||||||
|
Event any `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WsAuthMessage struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|||||||
+36
-45
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-socket/Enums/WsMessageFrom"
|
"go-socket/Enums/WsEventType"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
"github.com/coder/websocket/wsjson"
|
"github.com/coder/websocket/wsjson"
|
||||||
@@ -54,21 +54,7 @@ func ServeWsConnection(responseWriter http.ResponseWriter, request *http.Request
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessageStructCloseIfTimeout(user *User, message *Message) {
|
func WsSendMessageCloseIfTimeout(user *User, message any) {
|
||||||
if user.WsConn == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
err := wsjson.Write(ctx, user.WsConn, message)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("json write error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendMessageCloseIfTimeout(user *User, message *map[string]any) {
|
|
||||||
if user.WsConn == nil {
|
if user.WsConn == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,65 +80,64 @@ func sendToAllMessageCloseIfTimeout(message *map[string]any) {
|
|||||||
mu.RUnlock()
|
mu.RUnlock()
|
||||||
|
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
sendMessageCloseIfTimeout(user, message)
|
WsSendMessageCloseIfTimeout(user, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WsMessageSendToUser(to *User, message *Message) {
|
func WsSendToGroupAsUser(group *Group, sender *User, message string) error {
|
||||||
sendMessageStructCloseIfTimeout(to, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func WsSendToGroup(group *Group, sender *User, message string) error {
|
|
||||||
for groupUserId := range group.Users {
|
for groupUserId := range group.Users {
|
||||||
groupUser, err := CacheGetUserById(groupUserId)
|
groupUser, err := CacheGetUserById(groupUserId)
|
||||||
if err != nil || groupUser.Id == sender.Id {
|
if err != nil || groupUser.Id == sender.Id {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO update on groups rework
|
||||||
var msg = map[string]any{
|
var msg = map[string]any{
|
||||||
"type": WsMessageFrom.Group,
|
// "type": WsEventType.Group,
|
||||||
"from": group.Id,
|
"from": group.Id,
|
||||||
"sender": sender.Id,
|
"sender": sender.Id,
|
||||||
"content": message,
|
"content": message,
|
||||||
}
|
}
|
||||||
sendMessageCloseIfTimeout(groupUser, &msg)
|
WsSendMessageCloseIfTimeout(groupUser, &msg)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAuthenticatedMessage(user *User, userMessage *map[string]any) bool {
|
func handleAuthenticatedMessage(user *User, userMessage *map[string]any) bool {
|
||||||
sendMessageCloseIfTimeout(user, userMessage)
|
WsSendMessageCloseIfTimeout(user, userMessage)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUnauthenticatedMessage(ctx context.Context, user *User, userMessage *map[string]any) bool {
|
func handleUnauthenticatedMessage(ctx context.Context, user *User, userMessage *map[string]any) bool {
|
||||||
token, ok := (*userMessage)["token"].(string)
|
token, ok := (*userMessage)["token"].(string)
|
||||||
|
response := WsEventMessage{Type: WsEventType.Authentication}
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
var msg = map[string]any{
|
response.Event = WsAuthMessage{
|
||||||
"type": WsMessageFrom.Server,
|
Success: false,
|
||||||
"error": "no token in message",
|
Error: "no token in message",
|
||||||
}
|
}
|
||||||
sendMessageCloseIfTimeout(user, &msg)
|
WsSendMessageCloseIfTimeout(user, response)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
userId, err := TokenValidateGetId(token)
|
userId, err := TokenValidateGetId(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var msg = map[string]any{
|
response.Event = WsAuthMessage{
|
||||||
"type": WsMessageFrom.Server,
|
Success: false,
|
||||||
"error": "invalid token",
|
Error: "invalid token",
|
||||||
}
|
}
|
||||||
sendMessageCloseIfTimeout(user, &msg)
|
WsSendMessageCloseIfTimeout(user, response)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
userFromCache, err := CacheGetUserById(userId)
|
userFromCache, err := CacheGetUserById(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var msg = map[string]any{
|
response.Event = WsAuthMessage{
|
||||||
"type": WsMessageFrom.Server,
|
Success: false,
|
||||||
"error": "user not found",
|
Error: "user not found",
|
||||||
}
|
}
|
||||||
sendMessageCloseIfTimeout(user, &msg)
|
WsSendMessageCloseIfTimeout(user, response)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,25 +151,31 @@ func handleUnauthenticatedMessage(ctx context.Context, user *User, userMessage *
|
|||||||
|
|
||||||
err = DbGroupGetById(ctx, dbGroup)
|
err = DbGroupGetById(ctx, dbGroup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var msg = map[string]any{
|
response.Event = WsAuthMessage{
|
||||||
"type": "server",
|
Success: false,
|
||||||
"error": "invalid user data",
|
Error: "invalid user data",
|
||||||
}
|
}
|
||||||
sendMessageCloseIfTimeout(user, &msg)
|
WsSendMessageCloseIfTimeout(user, response)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
err = DbGroupGetMembers(ctx, dbGroup)
|
err = DbGroupGetMembers(ctx, dbGroup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var msg = map[string]any{
|
response.Event = WsAuthMessage{
|
||||||
"type": "server",
|
Success: false,
|
||||||
"error": "invalid user data",
|
Error: "invalid user data",
|
||||||
}
|
}
|
||||||
sendMessageCloseIfTimeout(user, &msg)
|
WsSendMessageCloseIfTimeout(user, response)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
CacheSaveGroup(dbGroup)
|
CacheSaveGroup(dbGroup)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response.Event = WsAuthMessage{
|
||||||
|
Success: true,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
WsSendMessageCloseIfTimeout(user, response)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user