forked from mirrors/0ad
Adds the server-side part of the javascript debugger. Refs #410
This was SVN commit r13238.
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
/* Copyright (C) 2013 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* 0 A.D. is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "precompiled.h"
|
||||
|
||||
#include "DebuggingServer.h"
|
||||
#include "ThreadDebugger.h"
|
||||
#include "ps/CLogger.h"
|
||||
#include "ps/Filesystem.h"
|
||||
#include "scripting/JSConversions.h"
|
||||
|
||||
CDebuggingServer* g_DebuggingServer = NULL;
|
||||
|
||||
const char* CDebuggingServer::header400 =
|
||||
"HTTP/1.1 400 Bad Request\r\n"
|
||||
"Content-Type: text/plain; charset=utf-8\r\n\r\n"
|
||||
"Invalid request";
|
||||
|
||||
void CDebuggingServer::GetAllCallstacks(std::stringstream& response)
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
response.str("");
|
||||
std::stringstream stream;
|
||||
uint nbrCallstacksWritten = 0;
|
||||
std::list<CThreadDebugger*>::iterator itr;
|
||||
if (m_ThreadDebuggers.size() > 0)
|
||||
{
|
||||
response << "[";
|
||||
for (itr = m_ThreadDebuggers.begin(); itr != m_ThreadDebuggers.end(); itr++)
|
||||
{
|
||||
if ((*itr)->GetIsInBreak())
|
||||
{
|
||||
stream.str("");
|
||||
std::string str = stream.str();
|
||||
(*itr)->GetCallstack(stream);
|
||||
str = stream.str();
|
||||
if (stream.str() != "")
|
||||
{
|
||||
if (nbrCallstacksWritten != 0)
|
||||
response << ",";
|
||||
response << "{" << "\"ThreadDebuggerID\" : " << (*itr)->GetID() << ", \"CallStack\" : " << stream.str() << "}";
|
||||
nbrCallstacksWritten++;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
response << "]";
|
||||
}
|
||||
}
|
||||
|
||||
void CDebuggingServer::GetStackFrameData(std::stringstream& response, uint nestingLevel, uint threadDebuggerID, STACK_INFO stackInfoKind)
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
response.str("");
|
||||
std::stringstream stream;
|
||||
std::list<CThreadDebugger*>::iterator itr;
|
||||
for (itr = m_ThreadDebuggers.begin(); itr != m_ThreadDebuggers.end(); itr++)
|
||||
{
|
||||
if ( (*itr)->GetID() == threadDebuggerID && (*itr)->GetIsInBreak())
|
||||
{
|
||||
(*itr)->GetStackFrameData(stream, nestingLevel, stackInfoKind);
|
||||
if (stream.str() != "")
|
||||
{
|
||||
response << stream.str();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
CDebuggingServer::CDebuggingServer() :
|
||||
m_MgContext(NULL)
|
||||
{
|
||||
m_BreakPointsSem = SDL_CreateSemaphore(0);
|
||||
ENSURE(m_BreakPointsSem);
|
||||
SDL_SemPost(m_BreakPointsSem);
|
||||
m_LastThreadDebuggerID = 0; // Next will be 1, 0 is reserved
|
||||
m_BreakRequestedByThread = false;
|
||||
m_BreakRequestedByUser = false;
|
||||
m_SettingSimultaneousThreadBreak = true;
|
||||
m_SettingBreakOnException = true;
|
||||
|
||||
EnableHTTP();
|
||||
LOGWARNING(L"Javascript debugging webserver enabled.");
|
||||
}
|
||||
|
||||
CDebuggingServer::~CDebuggingServer()
|
||||
{
|
||||
SDL_DestroySemaphore(m_BreakPointsSem);
|
||||
if (m_MgContext)
|
||||
{
|
||||
mg_stop(m_MgContext);
|
||||
m_MgContext = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
bool CDebuggingServer::SetNextDbgCmd(uint threadDebuggerID, DBGCMD dbgCmd)
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
std::list<CThreadDebugger*>::iterator itr;
|
||||
for (itr = m_ThreadDebuggers.begin(); itr != m_ThreadDebuggers.end(); itr++)
|
||||
{
|
||||
if ( (*itr)->GetID() == threadDebuggerID || threadDebuggerID == 0)
|
||||
{
|
||||
if (DBG_CMD_NONE == (*itr)->GetNextDbgCmd() && (*itr)->GetIsInBreak())
|
||||
{
|
||||
SetBreakRequestedByThread(false);
|
||||
SetBreakRequestedByUser(false);
|
||||
(*itr)->SetNextDbgCmd(dbgCmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CDebuggingServer::SetBreakRequestedByThread(bool Enabled)
|
||||
{
|
||||
CScopeLock lock(m_Mutex1);
|
||||
m_BreakRequestedByThread = Enabled;
|
||||
}
|
||||
|
||||
bool CDebuggingServer::GetBreakRequestedByThread()
|
||||
{
|
||||
CScopeLock lock(m_Mutex1);
|
||||
return m_BreakRequestedByThread;
|
||||
}
|
||||
|
||||
void CDebuggingServer::SetBreakRequestedByUser(bool Enabled)
|
||||
{
|
||||
CScopeLock lock(m_Mutex1);
|
||||
m_BreakRequestedByUser = Enabled;
|
||||
}
|
||||
|
||||
bool CDebuggingServer::GetBreakRequestedByUser()
|
||||
{
|
||||
CScopeLock lock(m_Mutex1);
|
||||
return m_BreakRequestedByUser;
|
||||
}
|
||||
|
||||
void CDebuggingServer::SetSettingBreakOnException(bool Enabled)
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
m_SettingBreakOnException = Enabled;
|
||||
}
|
||||
|
||||
void CDebuggingServer::SetSettingSimultaneousThreadBreak(bool Enabled)
|
||||
{
|
||||
CScopeLock lock(m_Mutex1);
|
||||
m_SettingSimultaneousThreadBreak = Enabled;
|
||||
}
|
||||
|
||||
bool CDebuggingServer::GetSettingBreakOnException()
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
return m_SettingBreakOnException;
|
||||
}
|
||||
|
||||
bool CDebuggingServer::GetSettingSimultaneousThreadBreak()
|
||||
{
|
||||
CScopeLock lock(m_Mutex1);
|
||||
return m_SettingSimultaneousThreadBreak;
|
||||
}
|
||||
|
||||
|
||||
static Status AddFileResponse(const VfsPath& pathname, const FileInfo& UNUSED(fileInfo), const uintptr_t cbData)
|
||||
{
|
||||
std::vector<std::string>& templates = *(std::vector<std::string>*)cbData;
|
||||
std::wstring str(pathname.string());
|
||||
templates.push_back(std::string(str.begin(), str.end()));
|
||||
return INFO::OK;
|
||||
}
|
||||
|
||||
void CDebuggingServer::EnumVfsJSFiles(std::stringstream& response)
|
||||
{
|
||||
VfsPath path = L"";
|
||||
VfsPaths pathnames;
|
||||
response.str("");
|
||||
|
||||
std::vector<std::string> templates;
|
||||
vfs::ForEachFile(g_VFS, "", AddFileResponse, (uintptr_t)&templates, L"*.js", vfs::DIR_RECURSIVE);
|
||||
|
||||
std::vector<std::string>::iterator itr;
|
||||
response << "[";
|
||||
for (itr = templates.begin(); itr != templates.end(); itr++)
|
||||
{
|
||||
if (itr != templates.begin())
|
||||
response << ",";
|
||||
response << "\"" << *itr << "\"";
|
||||
}
|
||||
response << "]";
|
||||
}
|
||||
|
||||
void CDebuggingServer::GetFile(std::string filename, std::stringstream& response)
|
||||
{
|
||||
CVFSFile file;
|
||||
if (file.Load(g_VFS, filename) != PSRETURN_OK)
|
||||
{
|
||||
response << "Failed to load the file contents";
|
||||
return;
|
||||
}
|
||||
|
||||
std::string code = file.DecodeUTF8(); // assume it's UTF-8
|
||||
response << code;
|
||||
}
|
||||
|
||||
|
||||
static void* MgDebuggingServerCallback_(mg_event event, struct mg_connection *conn, const struct mg_request_info *request_info)
|
||||
{
|
||||
CDebuggingServer* debuggingServer = (CDebuggingServer*)request_info->user_data;
|
||||
ENSURE(debuggingServer);
|
||||
return debuggingServer->MgDebuggingServerCallback(event, conn, request_info);
|
||||
}
|
||||
|
||||
void* CDebuggingServer::MgDebuggingServerCallback(mg_event event, struct mg_connection *conn, const struct mg_request_info *request_info)
|
||||
{
|
||||
void* handled = (void*)""; // arbitrary non-NULL pointer to indicate successful handling
|
||||
|
||||
const char* header200 =
|
||||
"HTTP/1.1 200 OK\r\n"
|
||||
"Access-Control-Allow-Origin: *\r\n" // TODO: not great for security
|
||||
"Content-Type: text/plain; charset=utf-8\r\n\r\n";
|
||||
|
||||
const char* header404 =
|
||||
"HTTP/1.1 404 Not Found\r\n"
|
||||
"Content-Type: text/plain; charset=utf-8\r\n\r\n"
|
||||
"Unrecognised URI";
|
||||
|
||||
switch (event)
|
||||
{
|
||||
case MG_NEW_REQUEST:
|
||||
{
|
||||
std::stringstream stream;
|
||||
std::string uri = request_info->uri;
|
||||
|
||||
if (uri == "/GetThreadDebuggerStatus")
|
||||
{
|
||||
GetThreadDebuggerStatus(stream);
|
||||
}
|
||||
else if (uri == "/EnumVfsJSFiles")
|
||||
{
|
||||
EnumVfsJSFiles(stream);
|
||||
}
|
||||
else if (uri == "/GetAllCallstacks")
|
||||
{
|
||||
GetAllCallstacks(stream);
|
||||
}
|
||||
else if (uri == "/Continue")
|
||||
{
|
||||
uint threadDebuggerID;
|
||||
if (!GetWebArgs(conn, request_info, "threadDebuggerID", threadDebuggerID))
|
||||
return handled;
|
||||
// TODO: handle the return value
|
||||
SetNextDbgCmd(threadDebuggerID, DBG_CMD_CONTINUE);
|
||||
}
|
||||
else if (uri == "/Break")
|
||||
{
|
||||
SetBreakRequestedByUser(true);
|
||||
}
|
||||
else if (uri == "/SetSettingSimultaneousThreadBreak")
|
||||
{
|
||||
std::string strEnabled;
|
||||
bool bEnabled = false;
|
||||
if (!GetWebArgs(conn, request_info, "enabled", strEnabled))
|
||||
return handled;
|
||||
// TODO: handle the return value
|
||||
if (strEnabled == "true")
|
||||
bEnabled = true;
|
||||
else if (strEnabled == "false")
|
||||
bEnabled = false;
|
||||
else
|
||||
return handled; // TODO: return an error state
|
||||
SetSettingSimultaneousThreadBreak(bEnabled);
|
||||
}
|
||||
else if (uri == "/GetSettingSimultaneousThreadBreak")
|
||||
{
|
||||
stream << "{ \"Enabled\" : " << (GetSettingSimultaneousThreadBreak() ? "true" : "false") << " } ";
|
||||
}
|
||||
else if (uri == "/SetSettingBreakOnException")
|
||||
{
|
||||
std::string strEnabled;
|
||||
bool bEnabled = false;
|
||||
if (!GetWebArgs(conn, request_info, "enabled", strEnabled))
|
||||
return handled;
|
||||
// TODO: handle the return value
|
||||
if (strEnabled == "true")
|
||||
bEnabled = true;
|
||||
else if (strEnabled == "false")
|
||||
bEnabled = false;
|
||||
else
|
||||
return handled; // TODO: return an error state
|
||||
SetSettingBreakOnException(bEnabled);
|
||||
}
|
||||
else if (uri == "/GetSettingBreakOnException")
|
||||
{
|
||||
stream << "{ \"Enabled\" : " << (GetSettingBreakOnException() ? "true" : "false") << " } ";
|
||||
}
|
||||
else if (uri == "/Step")
|
||||
{
|
||||
uint threadDebuggerID;
|
||||
if (!GetWebArgs(conn, request_info, "threadDebuggerID", threadDebuggerID))
|
||||
return handled;
|
||||
// TODO: handle the return value
|
||||
SetNextDbgCmd(threadDebuggerID, DBG_CMD_SINGLESTEP);
|
||||
}
|
||||
else if (uri == "/StepInto")
|
||||
{
|
||||
uint threadDebuggerID;
|
||||
if (!GetWebArgs(conn, request_info, "threadDebuggerID", threadDebuggerID))
|
||||
return handled;
|
||||
// TODO: handle the return value
|
||||
SetNextDbgCmd(threadDebuggerID, DBG_CMD_STEPINTO);
|
||||
}
|
||||
else if (uri == "/StepOut")
|
||||
{
|
||||
uint threadDebuggerID;
|
||||
if (!GetWebArgs(conn, request_info, "threadDebuggerID", threadDebuggerID))
|
||||
return handled;
|
||||
// TODO: handle the return value
|
||||
SetNextDbgCmd(threadDebuggerID, DBG_CMD_STEPOUT);
|
||||
}
|
||||
else if (uri == "/GetStackFrame")
|
||||
{
|
||||
uint nestingLevel;
|
||||
uint threadDebuggerID;
|
||||
if (!GetWebArgs(conn, request_info, "nestingLevel", nestingLevel) ||
|
||||
!GetWebArgs(conn, request_info, "threadDebuggerID", threadDebuggerID))
|
||||
{
|
||||
return handled;
|
||||
}
|
||||
GetStackFrameData(stream, nestingLevel, threadDebuggerID, STACK_INFO_LOCALS);
|
||||
}
|
||||
else if (uri == "/GetStackFrameThis")
|
||||
{
|
||||
uint nestingLevel;
|
||||
uint threadDebuggerID;
|
||||
if (!GetWebArgs(conn, request_info, "nestingLevel", nestingLevel) ||
|
||||
!GetWebArgs(conn, request_info, "threadDebuggerID", threadDebuggerID))
|
||||
{
|
||||
return handled;
|
||||
}
|
||||
GetStackFrameData(stream, nestingLevel, threadDebuggerID, STACK_INFO_THIS);
|
||||
}
|
||||
else if (uri == "/GetCurrentGlobalObject")
|
||||
{
|
||||
uint threadDebuggerID;
|
||||
if (!GetWebArgs(conn, request_info, "threadDebuggerID", threadDebuggerID))
|
||||
{
|
||||
return handled;
|
||||
}
|
||||
GetStackFrameData(stream, 0, threadDebuggerID, STACK_INFO_GLOBALOBJECT);
|
||||
}
|
||||
else if (uri == "/ToggleBreakpoint")
|
||||
{
|
||||
std::string filename;
|
||||
uint line;
|
||||
if (!GetWebArgs(conn, request_info, "filename", filename) ||
|
||||
!GetWebArgs(conn, request_info, "line", line))
|
||||
{
|
||||
return handled;
|
||||
}
|
||||
ToggleBreakPoint(filename, line);
|
||||
}
|
||||
else if (uri == "/GetFile")
|
||||
{
|
||||
std::string filename;
|
||||
if (!GetWebArgs(conn, request_info, "filename", filename))
|
||||
return handled;
|
||||
GetFile(filename, stream);
|
||||
}
|
||||
else
|
||||
{
|
||||
mg_printf(conn, "%s", header404);
|
||||
return handled;
|
||||
}
|
||||
|
||||
mg_printf(conn, "%s", header200);
|
||||
std::string str = stream.str();
|
||||
mg_write(conn, str.c_str(), str.length());
|
||||
return handled;
|
||||
}
|
||||
|
||||
case MG_HTTP_ERROR:
|
||||
return NULL;
|
||||
|
||||
case MG_EVENT_LOG:
|
||||
// Called by Mongoose's cry()
|
||||
LOGERROR(L"Mongoose error: %hs", request_info->log_message);
|
||||
return NULL;
|
||||
|
||||
case MG_INIT_SSL:
|
||||
return NULL;
|
||||
|
||||
default:
|
||||
debug_warn(L"Invalid Mongoose event type");
|
||||
return NULL;
|
||||
}
|
||||
};
|
||||
|
||||
void CDebuggingServer::EnableHTTP()
|
||||
{
|
||||
// Ignore multiple enablings
|
||||
if (m_MgContext)
|
||||
return;
|
||||
|
||||
const char *options[] = {
|
||||
"listening_ports", "127.0.0.1:9000", // bind to localhost for security
|
||||
"num_threads", "6", // enough for the browser's parallel connection limit
|
||||
NULL
|
||||
};
|
||||
m_MgContext = mg_start(MgDebuggingServerCallback_, this, options);
|
||||
ENSURE(m_MgContext);
|
||||
}
|
||||
|
||||
bool CDebuggingServer::GetWebArgs(struct mg_connection *conn, const struct mg_request_info* request_info, std::string argName, uint& arg)
|
||||
{
|
||||
if (!request_info->query_string)
|
||||
{
|
||||
mg_printf(conn, "%s (no query string)", header400);
|
||||
return false;
|
||||
}
|
||||
|
||||
char buf[256];
|
||||
|
||||
int len = mg_get_var(request_info->query_string, strlen(request_info->query_string), argName.c_str(), buf, ARRAY_SIZE(buf));
|
||||
if (len < 0)
|
||||
{
|
||||
mg_printf(conn, "%s (no '%s')", header400, argName.c_str());
|
||||
return false;
|
||||
}
|
||||
arg = atoi(buf);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CDebuggingServer::GetWebArgs(struct mg_connection *conn, const struct mg_request_info* request_info, std::string argName, std::string& arg)
|
||||
{
|
||||
if (!request_info->query_string)
|
||||
{
|
||||
mg_printf(conn, "%s (no query string)", header400);
|
||||
return false;
|
||||
}
|
||||
|
||||
char buf[256];
|
||||
int len = mg_get_var(request_info->query_string, strlen(request_info->query_string), argName.c_str(), buf, ARRAY_SIZE(buf));
|
||||
if (len < 0)
|
||||
{
|
||||
mg_printf(conn, "%s (no '%s')", header400, argName.c_str());
|
||||
return false;
|
||||
}
|
||||
arg = buf;
|
||||
return true;
|
||||
}
|
||||
|
||||
void CDebuggingServer::RegisterScriptinterface(std::string name, ScriptInterface* pScriptInterface)
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
CThreadDebugger* pThreadDebugger = new CThreadDebugger;
|
||||
// ThreadID 0 is reserved
|
||||
pThreadDebugger->Initialize(++m_LastThreadDebuggerID, name, pScriptInterface, this);
|
||||
m_ThreadDebuggers.push_back(pThreadDebugger);
|
||||
}
|
||||
|
||||
void CDebuggingServer::UnRegisterScriptinterface(ScriptInterface* pScriptInterface)
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
std::list<CThreadDebugger*>::iterator itr;
|
||||
for (itr = m_ThreadDebuggers.begin(); itr != m_ThreadDebuggers.end(); itr++)
|
||||
{
|
||||
if ((*itr)->CompareScriptInterfacePtr(pScriptInterface))
|
||||
{
|
||||
delete (*itr);
|
||||
m_ThreadDebuggers.erase(itr);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void CDebuggingServer::GetThreadDebuggerStatus(std::stringstream& response)
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
response.str("");
|
||||
std::list<CThreadDebugger*>::iterator itr;
|
||||
|
||||
response << "[";
|
||||
for (itr = m_ThreadDebuggers.begin(); itr != m_ThreadDebuggers.end(); itr++)
|
||||
{
|
||||
if (itr == m_ThreadDebuggers.begin())
|
||||
response << "{ ";
|
||||
else
|
||||
response << ",{ ";
|
||||
|
||||
response << "\"ThreadDebuggerID\" : " << (*itr)->GetID() << ",";
|
||||
response << "\"ScriptInterfaceName\" : \"" << (*itr)->GetName() << "\",";
|
||||
response << "\"ThreadInBreak\" : " << ((*itr)->GetIsInBreak() ? "true" : "false") << ",";
|
||||
response << "\"BreakFileName\" : \"" << (*itr)->GetBreakFileName() << "\",";
|
||||
response << "\"BreakLine\" : " << (*itr)->GetLastBreakLine();
|
||||
response << " }";
|
||||
}
|
||||
response << "]";
|
||||
}
|
||||
|
||||
void CDebuggingServer::ToggleBreakPoint(std::string filename, uint line)
|
||||
{
|
||||
// First, pass the message to all associated CThreadDebugger objects and check if one returns true (handled);
|
||||
{
|
||||
CScopeLock lock(m_Mutex);
|
||||
std::list<CThreadDebugger*>::iterator itr;
|
||||
for (itr = m_ThreadDebuggers.begin(); itr != m_ThreadDebuggers.end(); itr++)
|
||||
{
|
||||
if ((*itr)->ToggleBreakPoint(filename, line))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If the breakpoint isn't handled yet search the breakpoints registered in this class
|
||||
std::list<CBreakPoint>* pBreakPoints = NULL;
|
||||
double breakPointsLockID = AquireBreakPointAccess(&pBreakPoints);
|
||||
std::list<CBreakPoint>::iterator itr;
|
||||
|
||||
// If set, delete
|
||||
bool deleted = false;
|
||||
for (itr = pBreakPoints->begin(); itr != pBreakPoints->end(); itr++)
|
||||
{
|
||||
if ((*itr).m_Filename == filename && (*itr).m_UserLine == line)
|
||||
{
|
||||
itr = pBreakPoints->erase(itr);
|
||||
deleted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not set, set
|
||||
if (!deleted)
|
||||
{
|
||||
CBreakPoint bP;
|
||||
bP.m_Filename = filename;
|
||||
bP.m_UserLine = line;
|
||||
pBreakPoints->push_back(bP);
|
||||
}
|
||||
|
||||
ReleaseBreakPointAccess(breakPointsLockID);
|
||||
return;
|
||||
}
|
||||
|
||||
double CDebuggingServer::AquireBreakPointAccess(std::list<CBreakPoint>** breakPoints)
|
||||
{
|
||||
int ret;
|
||||
ret = SDL_SemWait(m_BreakPointsSem);
|
||||
ENSURE(0 == ret);
|
||||
(*breakPoints) = &m_BreakPoints;
|
||||
m_BreakPointsLockID = timer_Time();
|
||||
return m_BreakPointsLockID;
|
||||
}
|
||||
|
||||
void CDebuggingServer::ReleaseBreakPointAccess(double breakPointsLockID)
|
||||
{
|
||||
ENSURE(m_BreakPointsLockID == breakPointsLockID);
|
||||
SDL_SemPost(m_BreakPointsSem);
|
||||
}
|
||||
Reference in New Issue
Block a user