v3.2.t1
-the -d option will no longer directly run camera instances. it will instead utilize systemd to install camera services. -added -i, -l and -r options to manage camera services. -the app will now default work and output directories in the /var/opt directory instead of /var/www/html and /tmp, making it a better neighbour in the fact that it won't overwrite existing websites or tmp files. -install.sh will now create an unprivileged mow user that will be used to run camera services. all in effort to make the app a good neighour and follow good security practice.
This commit is contained in:
parent
25528617d5
commit
367ecc839b
49
install.sh
49
install.sh
|
@ -1,11 +1,50 @@
|
|||
#!/bin/sh
|
||||
if [ -f "/opt/mow/uninst" ]; then
|
||||
mow -u -f
|
||||
fi
|
||||
|
||||
if [ ! -d "/opt/mow" ]; then
|
||||
mkdir /opt/mow
|
||||
fi
|
||||
cp ./.build-mow/mow /opt/mow/bin
|
||||
|
||||
if [ ! -d "/var/opt/mow" ]; then
|
||||
mkdir /var/opt/mow
|
||||
fi
|
||||
|
||||
if [ ! -d "/etc/mow" ]; then
|
||||
mkdir /etc/mow
|
||||
fi
|
||||
|
||||
if [ ! -d "/var/opt/mow/buf" ]; then
|
||||
mkdir /var/opt/mow/buf
|
||||
fi
|
||||
|
||||
if [ ! -d "/var/opt/mow/web" ]; then
|
||||
mkdir /var/opt/mow/web
|
||||
fi
|
||||
|
||||
cp -v ./.build-mow/mow /opt/mow/bin
|
||||
|
||||
echo "writing /opt/mow/run"
|
||||
printf "#!/bin/sh\n" > /opt/mow/run
|
||||
printf "/opt/mow/bin \$1 \$2 \$3\n" >> /opt/mow/run
|
||||
chmod +x /opt/mow/run
|
||||
chmod +x /opt/mow/bin
|
||||
rm /usr/bin/mow
|
||||
ln -s /opt/mow/run /usr/bin/mow
|
||||
|
||||
echo "writing /opt/mow/uninst"
|
||||
printf "#!/bin/sh\n" > /opt/mow/uninst
|
||||
printf "rm -v /opt/mow/bin\n" >> /opt/mow/uninst
|
||||
printf "rm -v /opt/mow/run\n" >> /opt/mow/uninst
|
||||
printf "rm -v /opt/mow/uninst\n" >> /opt/mow/uninst
|
||||
printf "rm -v /usr/bin/mow\n" >> /opt/mow/uninst
|
||||
printf "rm -rv /opt/mow\n" >> /opt/mow/uninst
|
||||
printf "rm -r /var/opt/mow/buf\n" >> /opt/mow/uninst
|
||||
printf "deluser mow\n" >> /opt/mow/uninst
|
||||
|
||||
useradd -r mow
|
||||
|
||||
chown -R mow:mow /var/opt/mow
|
||||
|
||||
chmod -v +x /opt/mow/run
|
||||
chmod -v +x /opt/mow/bin
|
||||
chmod -v +x /opt/mow/uninst
|
||||
|
||||
ln -sv /opt/mow/run /usr/bin/mow
|
||||
|
|
|
@ -12,46 +12,55 @@
|
|||
|
||||
#include "camera.h"
|
||||
|
||||
Camera::Camera(QObject *parent) : QObject(parent)
|
||||
{
|
||||
shared.recordUrl.clear();
|
||||
shared.postCmd.clear();
|
||||
shared.camName.clear();
|
||||
|
||||
shared.retCode = 0;
|
||||
shared.imgThresh = 8000;
|
||||
shared.maxEvents = 100;
|
||||
shared.maxLogSize = 100000;
|
||||
shared.skipCmd = false;
|
||||
shared.postSecs = 60;
|
||||
shared.evMaxSecs = 30;
|
||||
shared.buffPath = QDir::tempPath();
|
||||
shared.webRoot = "/var/www/html";
|
||||
shared.webBg = "#485564";
|
||||
shared.webTxt = "#dee5ee";
|
||||
shared.webFont = "courier";
|
||||
}
|
||||
Camera::Camera(QObject *parent) : QObject(parent) {}
|
||||
|
||||
int Camera::start(const QStringList &args)
|
||||
{
|
||||
shared.conf = getParam("-c", args);
|
||||
if (rdConf(getParam("-c", args), &shared))
|
||||
{
|
||||
QDir().mkpath(shared.outDir);
|
||||
QDir().mkpath(shared.tmpDir);
|
||||
|
||||
if (rdConf(&shared))
|
||||
{
|
||||
auto thr1 = new QThread(nullptr);
|
||||
auto thr2 = new QThread(nullptr);
|
||||
auto thr3 = new QThread(nullptr);
|
||||
auto thr4 = new QThread(nullptr);
|
||||
QDir().mkpath(shared.outDir + "/events");
|
||||
QDir().mkpath(shared.tmpDir + "/live");
|
||||
QDir().mkpath(shared.tmpDir + "/logs");
|
||||
QDir().mkpath(shared.tmpDir + "/img");
|
||||
|
||||
new RecLoop(&shared, thr1, nullptr);
|
||||
new Upkeep(&shared, thr2, nullptr);
|
||||
new EventLoop(&shared, thr3, nullptr);
|
||||
new DetectLoop(&shared, thr4, nullptr);
|
||||
touch(shared.tmpDir + "/index.html");
|
||||
touch(shared.tmpDir + "/stream.html");
|
||||
touch(shared.tmpDir + "/stream.m3u8");
|
||||
|
||||
thr1->start();
|
||||
thr2->start();
|
||||
thr3->start();
|
||||
thr4->start();
|
||||
QFile::link(shared.tmpDir + "/live", shared.outDir + "/live");
|
||||
QFile::link(shared.tmpDir + "/logs", shared.outDir + "/logs");
|
||||
QFile::link(shared.tmpDir + "/img", shared.outDir + "/img");
|
||||
QFile::link(shared.tmpDir + "/index.html", shared.outDir + "/index.html");
|
||||
QFile::link(shared.tmpDir + "/stream.html", shared.outDir + "/stream.html");
|
||||
QFile::link(shared.tmpDir + "/stream.m3u8", shared.outDir + "/stream.m3u8");
|
||||
QFile::link(shared.outDir + "/events", shared.tmpDir + "/events");
|
||||
|
||||
if (!QDir::setCurrent(shared.tmpDir))
|
||||
{
|
||||
QTextStream(stderr) << "err: failed to change/create the current working directory to camera folder: '" << shared.outDir << "' does it exists?" << Qt::endl;
|
||||
|
||||
shared.retCode = ENOENT;
|
||||
}
|
||||
else
|
||||
{
|
||||
auto thr1 = new QThread(nullptr);
|
||||
auto thr2 = new QThread(nullptr);
|
||||
auto thr3 = new QThread(nullptr);
|
||||
auto thr4 = new QThread(nullptr);
|
||||
|
||||
new RecLoop(&shared, thr1, nullptr);
|
||||
new Upkeep(&shared, thr2, nullptr);
|
||||
new EventLoop(&shared, thr3, nullptr);
|
||||
new DetectLoop(&shared, thr4, nullptr);
|
||||
|
||||
thr1->start();
|
||||
thr2->start();
|
||||
thr3->start();
|
||||
thr4->start();
|
||||
}
|
||||
}
|
||||
|
||||
return shared.retCode;
|
||||
|
|
177
src/common.cpp
177
src/common.cpp
|
@ -194,6 +194,24 @@ bool rdConf(const QString &filePath, shared_t *share)
|
|||
}
|
||||
else
|
||||
{
|
||||
share->recordUrl.clear();
|
||||
share->postCmd.clear();
|
||||
share->camName.clear();
|
||||
|
||||
share->retCode = 0;
|
||||
share->imgThresh = 8000;
|
||||
share->maxEvents = 100;
|
||||
share->maxLogSize = 100000;
|
||||
share->skipCmd = false;
|
||||
share->postSecs = 60;
|
||||
share->evMaxSecs = 30;
|
||||
share->conf = filePath;
|
||||
share->buffPath = "/var/opt/" + QString(APP_BIN) + "/buf";
|
||||
share->webRoot = "/var/opt/" + QString(APP_BIN) + "/web";
|
||||
share->webBg = "#485564";
|
||||
share->webTxt = "#dee5ee";
|
||||
share->webFont = "courier";
|
||||
|
||||
QString line;
|
||||
|
||||
do
|
||||
|
@ -218,88 +236,47 @@ bool rdConf(const QString &filePath, shared_t *share)
|
|||
}
|
||||
|
||||
} while(!line.isEmpty());
|
||||
}
|
||||
|
||||
return share->retCode == 0;
|
||||
}
|
||||
|
||||
bool rdConf(shared_t *share)
|
||||
{
|
||||
if (rdConf(share->conf, share))
|
||||
{
|
||||
if (share->camName.isEmpty())
|
||||
{
|
||||
share->camName = QFileInfo(share->conf).fileName();
|
||||
}
|
||||
|
||||
share->outDir = QDir().cleanPath(share->webRoot) + "/" + share->camName;
|
||||
share->tmpDir = share->buffPath + "/" + APP_BIN + "/" + share->camName;
|
||||
|
||||
QDir().mkpath(share->outDir);
|
||||
QDir().mkpath(share->tmpDir);
|
||||
|
||||
QDir().mkpath(share->outDir + "/events");
|
||||
QDir().mkpath(share->tmpDir + "/live");
|
||||
QDir().mkpath(share->tmpDir + "/logs");
|
||||
QDir().mkpath(share->tmpDir + "/img");
|
||||
|
||||
touch(share->tmpDir + "/index.html");
|
||||
touch(share->tmpDir + "/stream.html");
|
||||
touch(share->tmpDir + "/stream.m3u8");
|
||||
|
||||
QFile::link(share->tmpDir + "/live", share->outDir + "/live");
|
||||
QFile::link(share->tmpDir + "/logs", share->outDir + "/logs");
|
||||
QFile::link(share->tmpDir + "/img", share->outDir + "/img");
|
||||
QFile::link(share->tmpDir + "/index.html", share->outDir + "/index.html");
|
||||
QFile::link(share->tmpDir + "/stream.html", share->outDir + "/stream.html");
|
||||
QFile::link(share->tmpDir + "/stream.m3u8", share->outDir + "/stream.m3u8");
|
||||
QFile::link(share->outDir + "/events", share->tmpDir + "/events");
|
||||
|
||||
if (!QDir::setCurrent(share->tmpDir))
|
||||
{
|
||||
QTextStream(stderr) << "err: failed to change/create the current working directory to camera folder: '" << share->outDir << "' does it exists?" << Qt::endl;
|
||||
|
||||
share->retCode = ENOENT;
|
||||
}
|
||||
share->outDir = QDir().cleanPath(share->webRoot) + "/" + share->camName;
|
||||
share->tmpDir = share->buffPath + "/" + APP_BIN + "/" + share->camName;
|
||||
share->servPath = QString("/var/opt/") + APP_BIN + "/" + APP_BIN + "." + share->camName + ".service";
|
||||
}
|
||||
|
||||
return share->retCode == 0;
|
||||
}
|
||||
|
||||
MultiInstance::MultiInstance(QObject *parent) : QObject(parent) {}
|
||||
|
||||
void MultiInstance::instStdout()
|
||||
void rmServices()
|
||||
{
|
||||
for (auto &&proc : procList)
|
||||
auto files = lsFilesInDir(QString("/var/opt/") + APP_BIN, ".service");
|
||||
|
||||
for (auto &&serv : files)
|
||||
{
|
||||
QTextStream(stdout) << proc->readAllStandardOutput();
|
||||
QProcess::execute("systemctl", {"stop", serv});
|
||||
QProcess::execute("systemctl", {"disable", serv});
|
||||
|
||||
QFile::remove(QString("/lib/systemd/system/") + serv);
|
||||
QFile::remove(QString("/var/opt/") + APP_BIN + "/" + serv);
|
||||
}
|
||||
|
||||
QProcess::execute("systemctl", {"daemon-reload"});
|
||||
}
|
||||
|
||||
void listServices()
|
||||
{
|
||||
auto files = lsFilesInDir(QString("/var/opt/") + APP_BIN, ".service");
|
||||
|
||||
for (auto &&serv : files)
|
||||
{
|
||||
QTextStream(stdout) << serv << ": "; QProcess::execute("systemctl", {"is-active", serv});
|
||||
}
|
||||
}
|
||||
|
||||
void MultiInstance::instStderr()
|
||||
{
|
||||
for (auto &&proc : procList)
|
||||
{
|
||||
QTextStream(stderr) << proc->readAllStandardError();
|
||||
}
|
||||
}
|
||||
|
||||
void MultiInstance::procChanged(QProcess::ProcessState newState)
|
||||
{
|
||||
Q_UNUSED(newState)
|
||||
|
||||
for (auto &&proc : procList)
|
||||
{
|
||||
if (proc->state() == QProcess::Running)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
QCoreApplication::quit();
|
||||
}
|
||||
|
||||
int MultiInstance::start(const QStringList &args)
|
||||
int loadServices(const QStringList &args)
|
||||
{
|
||||
auto ret = ENOENT;
|
||||
auto path = QDir().cleanPath(getParam("-d", args));
|
||||
|
@ -307,35 +284,77 @@ int MultiInstance::start(const QStringList &args)
|
|||
|
||||
if (!QDir(path).exists())
|
||||
{
|
||||
QTextStream(stderr) << "err: the supplied directory in -d '" << path << "' does not exists or is not a directory.";
|
||||
QTextStream(stderr) << "err: the supplied directory in -d '" << path << "' does not exists or is not a directory." << Qt::endl;
|
||||
}
|
||||
else if (files.isEmpty())
|
||||
{
|
||||
QTextStream(stderr) << "err: no config files found in '" << path << "'";
|
||||
QTextStream(stderr) << "err: no config files found in '" << path << "'" << Qt::endl;
|
||||
}
|
||||
else
|
||||
{
|
||||
ret = 0;
|
||||
|
||||
QTextStream(stdout) << "loading conf files from dir: " << path << Qt::endl;
|
||||
|
||||
for (auto &&conf : files)
|
||||
{
|
||||
auto proc = new QProcess(this);
|
||||
shared_t shared;
|
||||
|
||||
QStringList subArgs;
|
||||
if (!rdConf(path + "/" + conf, &shared))
|
||||
{
|
||||
ret = shared.retCode; break;
|
||||
}
|
||||
else
|
||||
{
|
||||
QTextStream(stdout) << conf << " --" << Qt::endl;
|
||||
|
||||
subArgs << "-c" << path + "/" + conf;
|
||||
QFile file(shared.servPath);
|
||||
|
||||
connect(proc, &QProcess::readyReadStandardOutput, this, &MultiInstance::instStdout);
|
||||
connect(proc, &QProcess::readyReadStandardError, this, &MultiInstance::instStderr);
|
||||
connect(proc, &QProcess::stateChanged, this, &MultiInstance::procChanged);
|
||||
if (!file.open(QFile::ReadWrite | QFile::Truncate))
|
||||
{
|
||||
QTextStream(stderr) << "err: failed to open service file: " << shared.servPath << " for writing. reason: " << file.errorString();
|
||||
|
||||
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, proc, &QProcess::terminate);
|
||||
ret = EACCES; file.close(); break;
|
||||
}
|
||||
else
|
||||
{
|
||||
file.write("[Unit]\n");
|
||||
file.write("Description=" + QByteArray(APP_NAME) + " Camera - " + shared.camName.toUtf8() + "\n");
|
||||
file.write("After=network.target\n\n");
|
||||
file.write("[Service]\n");
|
||||
file.write("Type=simple\n");
|
||||
file.write("User=" + QByteArray(APP_BIN) + "\n");
|
||||
file.write("Restart=always\n");
|
||||
file.write("RestartSec=5\n");
|
||||
file.write("TimeoutStopSec=infinity\n");
|
||||
file.write("ExecStart=/usr/bin/env " + QByteArray(APP_BIN) + " -c " + shared.conf.toUtf8() + "\n\n");
|
||||
file.write("[Install]\n");
|
||||
file.write("WantedBy=multi-user.target");
|
||||
file.close();
|
||||
|
||||
proc->setProgram(APP_BIN);
|
||||
proc->setArguments(subArgs);
|
||||
proc->start();
|
||||
auto servName = QFileInfo(shared.servPath).fileName();
|
||||
|
||||
procList.append(proc);
|
||||
if (!QFile::link(shared.servPath, "/lib/systemd/system/" + servName))
|
||||
{
|
||||
ret = EACCES; break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (ret == 0) ret = QProcess::execute("systemctl", {"daemon-reload"});
|
||||
if (ret == 0) ret = QProcess::execute("systemctl", {"enable", servName});
|
||||
if (ret == 0) ret = QProcess::execute("systemctl", {"start", servName});
|
||||
|
||||
if (ret != 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
QTextStream(stdout) << "Successfully loaded camera service: " << servName << Qt::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
29
src/common.h
29
src/common.h
|
@ -26,10 +26,11 @@
|
|||
#include <QTimer>
|
||||
#include <QStringList>
|
||||
#include <QMutex>
|
||||
#include <iostream>
|
||||
|
||||
using namespace std;
|
||||
|
||||
#define APP_VER "3.1"
|
||||
#define APP_VER "3.2.t1"
|
||||
#define APP_NAME "Motion Watch"
|
||||
#define APP_BIN "mow"
|
||||
#define REC_LOG_NAME "rec_log_lines.html"
|
||||
|
@ -66,6 +67,7 @@ struct shared_t
|
|||
QString webTxt;
|
||||
QString webFont;
|
||||
QString webRoot;
|
||||
QString servPath;
|
||||
bool skipCmd;
|
||||
int evMaxSecs;
|
||||
int postSecs;
|
||||
|
@ -82,7 +84,9 @@ QStringList listFacingFiles(const QString &path, const QString &ext, const QDate
|
|||
QStringList backwardFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs);
|
||||
QStringList forwardFacingFiles(const QString &path, const QString &ext, const QDateTime &stamp, int secs);
|
||||
bool rdConf(const QString &filePath, shared_t *share);
|
||||
bool rdConf(shared_t *share);
|
||||
int loadServices(const QStringList &args);
|
||||
void listServices();
|
||||
void rmServices();
|
||||
void touch(const QString &path);
|
||||
void rdLine(const QString ¶m, const QString &line, QString *value);
|
||||
void rdLine(const QString ¶m, const QString &line, int *value);
|
||||
|
@ -90,25 +94,4 @@ void enforceMaxEvents(shared_t *share);
|
|||
void enforceMaxImages();
|
||||
void enforceMaxVids();
|
||||
|
||||
class MultiInstance : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private:
|
||||
|
||||
QList<QProcess*> procList;
|
||||
|
||||
private slots:
|
||||
|
||||
void instStdout();
|
||||
void instStderr();
|
||||
void procChanged(QProcess::ProcessState newState);
|
||||
|
||||
public:
|
||||
|
||||
explicit MultiInstance(QObject *parent = nullptr);
|
||||
|
||||
int start(const QStringList &args);
|
||||
};
|
||||
|
||||
#endif // COMMON_H
|
||||
|
|
72
src/main.cpp
72
src/main.cpp
|
@ -13,6 +13,27 @@
|
|||
#include "common.h"
|
||||
#include "camera.h"
|
||||
|
||||
void showHelp()
|
||||
{
|
||||
QTextStream(stdout) << APP_NAME << " " << APP_VER << Qt::endl << Qt::endl;
|
||||
QTextStream(stdout) << "Usage: mow <argument>" << Qt::endl << Qt::endl;
|
||||
QTextStream(stdout) << "-h : display usage information about this application." << Qt::endl;
|
||||
QTextStream(stdout) << "-c : path to the config file used to run a single camera instance." << Qt::endl;
|
||||
QTextStream(stdout) << "-d : path to a directory that can contain multiple config files." << Qt::endl;
|
||||
QTextStream(stdout) << " each file found in the directory will be used to create a " << Qt::endl;
|
||||
QTextStream(stdout) << " systemd service for the camera. already existing camera" << Qt::endl;
|
||||
QTextStream(stdout) << " services will be reloaded and restarted. any services without" << Qt::endl;
|
||||
QTextStream(stdout) << " a config file will be removed." << Qt::endl;
|
||||
QTextStream(stdout) << "-i : this is the same as -d except a directory does not need to be" << Qt::endl;
|
||||
QTextStream(stdout) << " provided. the default directory /etc/" << APP_BIN << " will be used." << Qt::endl;
|
||||
QTextStream(stdout) << "-v : display the current version." << Qt::endl;
|
||||
QTextStream(stdout) << "-u : uninstall the entire app from your system, including all" << Qt::endl;
|
||||
QTextStream(stdout) << " camera services." << Qt::endl;
|
||||
QTextStream(stdout) << "-f : force an action without pausing for user confirmation." << Qt::endl;
|
||||
QTextStream(stdout) << "-l : list all camera services along with statuses." << Qt::endl;
|
||||
QTextStream(stdout) << "-r : remove all camera services." << Qt::endl;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
|
@ -25,14 +46,7 @@ int main(int argc, char** argv)
|
|||
|
||||
if (args.contains("-h"))
|
||||
{
|
||||
QTextStream(stdout) << "Motion Watch " << APP_VER << Qt::endl << Qt::endl;
|
||||
QTextStream(stdout) << "Usage: mow <argument>" << Qt::endl << Qt::endl;
|
||||
QTextStream(stdout) << "-h : display usage information about this application." << Qt::endl;
|
||||
QTextStream(stdout) << "-c : path to the config file used to run a single camera instance." << Qt::endl;
|
||||
QTextStream(stdout) << "-d : path to a directory that can contain multiple config files." << Qt::endl;
|
||||
QTextStream(stdout) << " each file found in the directory will be used to run a" << Qt::endl;
|
||||
QTextStream(stdout) << " camera instance." << Qt::endl;
|
||||
QTextStream(stdout) << "-v : display the current version." << Qt::endl << Qt::endl;
|
||||
showHelp();
|
||||
}
|
||||
else if (args.contains("-v"))
|
||||
{
|
||||
|
@ -40,14 +54,17 @@ int main(int argc, char** argv)
|
|||
}
|
||||
else if (args.contains("-d"))
|
||||
{
|
||||
auto *muli = new MultiInstance(&app);
|
||||
rmServices(); ret = loadServices(args);
|
||||
}
|
||||
else if (args.contains("-l"))
|
||||
{
|
||||
listServices();
|
||||
}
|
||||
else if (args.contains("-i"))
|
||||
{
|
||||
args = {"-d", "/etc/" + QString(APP_BIN)};
|
||||
|
||||
ret = muli->start(args);
|
||||
|
||||
if (ret == 0)
|
||||
{
|
||||
ret = QCoreApplication::exec();
|
||||
}
|
||||
rmServices(); ret = loadServices(args);
|
||||
}
|
||||
else if (args.contains("-c"))
|
||||
{
|
||||
|
@ -60,9 +77,32 @@ int main(int argc, char** argv)
|
|||
ret = QCoreApplication::exec();
|
||||
}
|
||||
}
|
||||
else if (args.contains("-u"))
|
||||
{
|
||||
if (args.contains("-f"))
|
||||
{
|
||||
rmServices(); QProcess::execute("/opt/mow/uninst");
|
||||
}
|
||||
else
|
||||
{
|
||||
char ans;
|
||||
|
||||
std::cout << "This will completely uninstall " << APP_NAME << " from your system. continue y/n? ";
|
||||
std::cin >> ans;
|
||||
|
||||
if (ans == 'y' || ans == 'Y')
|
||||
{
|
||||
rmServices(); QProcess::execute("/opt/mow/uninst");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (args.contains("-r"))
|
||||
{
|
||||
rmServices();
|
||||
}
|
||||
else
|
||||
{
|
||||
QTextStream(stderr) << "err: no config file(s) were given in -c" << Qt::endl;
|
||||
showHelp();
|
||||
}
|
||||
|
||||
return ret;
|
||||
|
|
Loading…
Reference in New Issue
Block a user