mirror of
https://github.com/mgerb/go-discord-bot
synced 2026-01-10 09:02:49 +00:00
feat: add user event log to admin page
This commit is contained in:
@@ -43,7 +43,7 @@ export class ClipPlayerControl extends React.Component<IProps, IState> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
this.checkExtension(sound.extension) && (
|
this.checkExtension(sound.extension) && (
|
||||||
<div className="flex flex--v-center">
|
<div className="flex flex--center">
|
||||||
<a
|
<a
|
||||||
href={`/public/${type.toLowerCase()}/` + sound.name + '.' + sound.extension}
|
href={`/public/${type.toLowerCase()}/` + sound.name + '.' + sound.extension}
|
||||||
download
|
download
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ export class SoundList extends React.Component<Props, State> {
|
|||||||
? soundList.map((sound: SoundType, index: number) => {
|
? soundList.map((sound: SoundType, index: number) => {
|
||||||
return (
|
return (
|
||||||
<div key={index} className="sound-list__item">
|
<div key={index} className="sound-list__item">
|
||||||
<div className="text-wrap">{(sound.prefix || '') + sound.name}</div>
|
<div className="text-wrap">
|
||||||
|
{(type === 'sounds' && sound.prefix ? sound.prefix : '') + sound.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ClipPlayerControl showDiscordPlay={showDiscordPlay} sound={sound} type={type} />
|
<ClipPlayerControl showDiscordPlay={showDiscordPlay} sound={sound} type={type} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ export const UploadHistory = ({ sounds, showDiscordPlay }: IProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card__header">Upload History</div>
|
<div className="card__header">Upload History</div>
|
||||||
<table className="table">
|
<table className="table table--ellipsis">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="hide-tiny">Date</th>
|
<th className="hide-small">Date</th>
|
||||||
<th>Sound</th>
|
<th>Sound</th>
|
||||||
<th className="hide-tiny">Ext</th>
|
<th className="hide-small">Ext</th>
|
||||||
<th>Username</th>
|
<th>User</th>
|
||||||
<th className="hide-tiny">Email</th>
|
<th className="hide-small">Email</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -29,15 +29,15 @@ export const UploadHistory = ({ sounds, showDiscordPlay }: IProps) => {
|
|||||||
const formattedDate = DateTime.fromISO(s.created_at).toLocaleString();
|
const formattedDate = DateTime.fromISO(s.created_at).toLocaleString();
|
||||||
return (
|
return (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td className="hide-tiny" title={formattedDate}>
|
<td className="hide-small" title={formattedDate}>
|
||||||
{formattedDate}
|
{formattedDate}
|
||||||
</td>
|
</td>
|
||||||
<td title={s.name}>{s.name}</td>
|
<td title={s.name}>{s.name}</td>
|
||||||
<td className="hide-tiny" title={s.extension}>
|
<td className="hide-small" title={s.extension}>
|
||||||
{s.extension}
|
{s.extension}
|
||||||
</td>
|
</td>
|
||||||
<td title={s.user.username}>{s.user.username}</td>
|
<td title={s.user.username}>{s.user.username}</td>
|
||||||
<td className="hide-tiny" title={s.user.email}>
|
<td className="hide-small" title={s.user.email}>
|
||||||
{s.user.email}
|
{s.user.email}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from './claims';
|
|||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
export * from './sound';
|
export * from './sound';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './user-event-log';
|
||||||
export * from './video-archive';
|
export * from './video-archive';
|
||||||
|
|||||||
11
client/app/model/user-event-log.ts
Normal file
11
client/app/model/user-event-log.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IUser } from './user';
|
||||||
|
|
||||||
|
export interface IUserEventLog {
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
deleted_at?: string;
|
||||||
|
id: number;
|
||||||
|
updated_at: string;
|
||||||
|
user: IUser;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { IUserEventLog } from '../../model';
|
||||||
|
import { UserEventLogService } from '../../services';
|
||||||
|
|
||||||
interface IProps {}
|
interface IProps {}
|
||||||
|
|
||||||
interface IState {}
|
interface IState {
|
||||||
|
userEventLogs: IUserEventLog[];
|
||||||
|
}
|
||||||
|
|
||||||
export class Admin extends React.Component<IProps, IState> {
|
export class Admin extends React.Component<IProps, IState> {
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
userEventLogs: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
componentDidMount() {
|
||||||
|
UserEventLogService.getUserEventLogs().then(userEventLogs => {
|
||||||
|
this.setState({
|
||||||
|
userEventLogs,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderUserEventLogs() {
|
||||||
|
return this.state.userEventLogs.map(({ id, user, content, created_at }, index) => {
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>{id}</td>
|
||||||
|
<td>{created_at}</td>
|
||||||
|
<td>{user.username}</td>
|
||||||
|
<td>{content}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <div className="content">TODO:</div>;
|
return (
|
||||||
|
<div className="content">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card__header">User Event Log</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Content</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{this.renderUserEventLogs()}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,10 +32,19 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex--center {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.flex--v-center {
|
.flex--v-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex--h-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@include tinyScreen {
|
@include tinyScreen {
|
||||||
@@ -77,3 +86,9 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hide-small {
|
||||||
|
@include smallScreen {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
thead {
|
thead {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
padding: 10px 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--ellipsis {
|
||||||
td,
|
td,
|
||||||
th {
|
th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './axios.service';
|
|||||||
export * from './oauth.service';
|
export * from './oauth.service';
|
||||||
export * from './sound.service';
|
export * from './sound.service';
|
||||||
export * from './storage.service';
|
export * from './storage.service';
|
||||||
|
export * from './user-event-log.service';
|
||||||
|
|||||||
9
client/app/services/user-event-log.service.ts
Normal file
9
client/app/services/user-event-log.service.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { IUserEventLog } from '../model';
|
||||||
|
import { axios } from './axios.service';
|
||||||
|
|
||||||
|
export class UserEventLogService {
|
||||||
|
public static async getUserEventLogs(): Promise<IUserEventLog[]> {
|
||||||
|
const resp = await axios.get('/api/user-event-log');
|
||||||
|
return resp.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mgerb/go-discord-bot/server/db"
|
||||||
|
"github.com/mgerb/go-discord-bot/server/webserver/model"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/mgerb/go-discord-bot/server/config"
|
"github.com/mgerb/go-discord-bot/server/config"
|
||||||
"github.com/mgerb/go-discord-bot/server/util"
|
"github.com/mgerb/go-discord-bot/server/util"
|
||||||
@@ -104,10 +107,10 @@ func (conn *AudioConnection) handleMessage(m *discordgo.MessageCreate) {
|
|||||||
conn.clipAudio(m)
|
conn.clipAudio(m)
|
||||||
|
|
||||||
case "random":
|
case "random":
|
||||||
conn.PlayRandomAudio(m)
|
conn.PlayRandomAudio(m, nil)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
conn.PlayAudio(command, m)
|
conn.PlayAudio(command, m, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,20 +170,20 @@ func (conn *AudioConnection) summon(m *discordgo.MessageCreate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// play a random sound clip
|
// play a random sound clip
|
||||||
func (conn *AudioConnection) PlayRandomAudio(m *discordgo.MessageCreate) {
|
func (conn *AudioConnection) PlayRandomAudio(m *discordgo.MessageCreate, userID *string) {
|
||||||
files, _ := ioutil.ReadDir(config.Config.SoundsPath)
|
files, _ := ioutil.ReadDir(config.Config.SoundsPath)
|
||||||
if len(files) > 0 {
|
if len(files) > 0 {
|
||||||
randomIndex := rand.Intn(len(files))
|
randomIndex := rand.Intn(len(files))
|
||||||
arr := strings.Split(files[randomIndex].Name(), ".")
|
arr := strings.Split(files[randomIndex].Name(), ".")
|
||||||
if len(arr) > 0 && arr[0] != "" {
|
if len(arr) > 0 && arr[0] != "" {
|
||||||
conn.PlayAudio(arr[0], m)
|
conn.PlayAudio(arr[0], m, userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayAudio - play audio in channel that user is in
|
// PlayAudio - play audio in channel that user is in
|
||||||
// if MessageCreate is null play in current channel
|
// if MessageCreate is null play in current channel
|
||||||
func (conn *AudioConnection) PlayAudio(soundName string, m *discordgo.MessageCreate) {
|
func (conn *AudioConnection) PlayAudio(soundName string, m *discordgo.MessageCreate, userID *string) {
|
||||||
|
|
||||||
// summon bot to channel if new message passed in
|
// summon bot to channel if new message passed in
|
||||||
if m != nil {
|
if m != nil {
|
||||||
@@ -204,6 +207,24 @@ func (conn *AudioConnection) PlayAudio(soundName string, m *discordgo.MessageCre
|
|||||||
select {
|
select {
|
||||||
case conn.SoundQueue <- soundName:
|
case conn.SoundQueue <- soundName:
|
||||||
|
|
||||||
|
var newUserID string
|
||||||
|
fromWebUI := false
|
||||||
|
|
||||||
|
// from discord
|
||||||
|
if m != nil && m.Author != nil {
|
||||||
|
newUserID = m.Author.ID
|
||||||
|
} else {
|
||||||
|
fromWebUI = true
|
||||||
|
newUserID = *userID
|
||||||
|
}
|
||||||
|
|
||||||
|
// log event when user plays sound clip
|
||||||
|
err := model.LogSoundPlayedEvent(db.GetConn(), newUserID, soundName, fromWebUI)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
|
|
||||||
// CustomClaims -
|
// CustomClaims -
|
||||||
type CustomClaims struct {
|
type CustomClaims struct {
|
||||||
ID string `json:"id"`
|
UserID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Discriminator string `json:"discriminator"`
|
Discriminator string `json:"discriminator"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
var Migrations []interface{} = []interface{}{
|
// Migrations - list of database migrations
|
||||||
|
var Migrations = []interface{}{
|
||||||
&Message{},
|
&Message{},
|
||||||
&Attachment{},
|
&Attachment{},
|
||||||
&User{},
|
&User{},
|
||||||
&VideoArchive{},
|
&VideoArchive{},
|
||||||
&Sound{},
|
&Sound{},
|
||||||
|
&UserEventLog{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ type Sound struct {
|
|||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func SoundCreate(conn *gorm.DB, sound *Sound) error {
|
func SoundSave(conn *gorm.DB, sound *Sound) error {
|
||||||
return conn.Create(sound).Error
|
return conn.Create(sound).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func SoundList(conn *gorm.DB) ([]Sound, error) {
|
func SoundGet(conn *gorm.DB) ([]Sound, error) {
|
||||||
sound := []Sound{}
|
sound := []Sound{}
|
||||||
err := conn.Set("gorm:auto_preload", true).Find(&sound).Error
|
err := conn.Set("gorm:auto_preload", true).Find(&sound).Error
|
||||||
return sound, err
|
return sound, err
|
||||||
|
|||||||
51
server/webserver/model/user_event_log.go
Normal file
51
server/webserver/model/user_event_log.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jinzhu/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserEventLog - logger for user events
|
||||||
|
type UserEventLog struct {
|
||||||
|
ID uint `gorm:"primary_key; auto_increment; not null" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt *time.Time `json:"deleted_at"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
User User `json:"user"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserEventLogSave -
|
||||||
|
func UserEventLogSave(conn *gorm.DB, m *UserEventLog) error {
|
||||||
|
return conn.Save(m).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserEventLogGet - returns all messages - must use paging
|
||||||
|
func UserEventLogGet(conn *gorm.DB, page int) ([]*UserEventLog, error) {
|
||||||
|
userEventLog := []*UserEventLog{}
|
||||||
|
err := conn.Offset(page*100).Limit(100).Order("created_at desc", true).Preload("User").Find(&userEventLog).Error
|
||||||
|
return userEventLog, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogSoundPlayedEvent - log event when user plays sound clip
|
||||||
|
func LogSoundPlayedEvent(conn *gorm.DB, userID, soundName string, fromWebUI bool) error {
|
||||||
|
|
||||||
|
var content string
|
||||||
|
|
||||||
|
// from discord
|
||||||
|
if !fromWebUI {
|
||||||
|
content = "played sound clip: " + soundName
|
||||||
|
} else {
|
||||||
|
content = "played sound clip from web UI: " + soundName
|
||||||
|
}
|
||||||
|
|
||||||
|
// log play event
|
||||||
|
userEventLog := &UserEventLog{
|
||||||
|
UserID: userID,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserEventLogSave(conn, userEventLog)
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ func AddSoundRoutes(group *gin.RouterGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func listSoundHandler(c *gin.Context) {
|
func listSoundHandler(c *gin.Context) {
|
||||||
archives, err := model.SoundList(db.GetConn())
|
archives, err := model.SoundGet(db.GetConn())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.InternalError(c, err)
|
response.InternalError(c, err)
|
||||||
@@ -36,6 +36,9 @@ func listSoundHandler(c *gin.Context) {
|
|||||||
func postSoundPlayHandler(c *gin.Context) {
|
func postSoundPlayHandler(c *gin.Context) {
|
||||||
connections := bothandlers.ActiveConnections
|
connections := bothandlers.ActiveConnections
|
||||||
|
|
||||||
|
oc, _ := c.Get("claims")
|
||||||
|
claims, _ := oc.(*middleware.CustomClaims)
|
||||||
|
|
||||||
params := struct {
|
params := struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}{}
|
}{}
|
||||||
@@ -47,9 +50,9 @@ func postSoundPlayHandler(c *gin.Context) {
|
|||||||
if len(connections) == 1 && params.Name != "" {
|
if len(connections) == 1 && params.Name != "" {
|
||||||
for _, con := range connections {
|
for _, con := range connections {
|
||||||
if params.Name == "random" {
|
if params.Name == "random" {
|
||||||
con.PlayRandomAudio(nil)
|
con.PlayRandomAudio(nil, &claims.UserID)
|
||||||
} else {
|
} else {
|
||||||
con.PlayAudio(params.Name, nil)
|
con.PlayAudio(params.Name, nil, &claims.UserID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +92,7 @@ func postSoundHandler(c *gin.Context) {
|
|||||||
log.Info(claims.Username, "uploaded", config.Config.SoundsPath+"/"+file.Filename)
|
log.Info(claims.Username, "uploaded", config.Config.SoundsPath+"/"+file.Filename)
|
||||||
|
|
||||||
// save who uploaded the clip into the database
|
// save who uploaded the clip into the database
|
||||||
uploadSaveDB(claims.ID, file.Filename)
|
uploadSaveDB(claims.UserID, file.Filename)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Error(err)
|
||||||
@@ -106,7 +109,7 @@ func uploadSaveDB(userID, filename string) {
|
|||||||
extension := splitFilename[len(splitFilename)-1]
|
extension := splitFilename[len(splitFilename)-1]
|
||||||
name := strings.Join(splitFilename[:len(splitFilename)-1], ".")
|
name := strings.Join(splitFilename[:len(splitFilename)-1], ".")
|
||||||
|
|
||||||
model.SoundCreate(db.GetConn(), &model.Sound{
|
model.SoundSave(db.GetConn(), &model.Sound{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Extension: extension,
|
Extension: extension,
|
||||||
|
|||||||
33
server/webserver/routes/user_event_log.go
Normal file
33
server/webserver/routes/user_event_log.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mgerb/go-discord-bot/server/db"
|
||||||
|
"github.com/mgerb/go-discord-bot/server/webserver/middleware"
|
||||||
|
"github.com/mgerb/go-discord-bot/server/webserver/model"
|
||||||
|
"github.com/mgerb/go-discord-bot/server/webserver/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddUserEventLogRoutes -
|
||||||
|
func AddUserEventLogRoutes(group *gin.RouterGroup) {
|
||||||
|
group.GET("/user-event-log", middleware.AuthorizedJWT(), middleware.AuthPermissions(middleware.PermAdmin), listEventLogHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listEventLogHandler(c *gin.Context) {
|
||||||
|
|
||||||
|
page, err := strconv.Atoi(c.Query("page"))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
page = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
userEventLogs, err := model.UserEventLogGet(db.GetConn(), page)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
response.InternalError(c, err)
|
||||||
|
} else {
|
||||||
|
response.Success(c, userEventLogs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ func getRouter() *gin.Engine {
|
|||||||
routes.AddConfigRoutes(api)
|
routes.AddConfigRoutes(api)
|
||||||
routes.AddSoundRoutes(api)
|
routes.AddSoundRoutes(api)
|
||||||
routes.AddVideoArchiveRoutes(api)
|
routes.AddVideoArchiveRoutes(api)
|
||||||
|
routes.AddUserEventLogRoutes(api)
|
||||||
|
|
||||||
router.NoRoute(func(c *gin.Context) {
|
router.NoRoute(func(c *gin.Context) {
|
||||||
if strings.HasPrefix(c.Request.URL.String(), "/api/") {
|
if strings.HasPrefix(c.Request.URL.String(), "/api/") {
|
||||||
|
|||||||
Reference in New Issue
Block a user