moveToTrash/Unix: refactor to use openat()/mkdirat()/renameat()
This ensures much better security against race conditions and attacks, at the expense of a few more system calls. On first run (when no trash dir is yet present): openat(AT_FDCWD, "/home/tjmaciei/.qttest/share/Trash", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = -1 ENOENT (No such file or directory) mkdirat(AT_FDCWD, "/home/tjmaciei/.qttest/share/Trash", 0700) = 0 openat(AT_FDCWD, "/home/tjmaciei/.qttest/share/Trash", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 5 newfstatat(5, "", {st_mode=S_IFDIR|0700, st_size=0, ...}, AT_EMPTY_PATH) = 0 getuid() = 1000 openat(5, "files", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = -1 ENOENT (No such file or directory) mkdirat(5, "files", 0700) = 0 openat(5, "files", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 6 openat(5, "info", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = -1 ENOENT (No such file or directory) mkdirat(5, "info", 0700) = 0 openat(5, "info", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 7 close(5) = 0 openat(7, "tst_qfile.moveToTrashOpenFile.fjYRxv.trashinfo", O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0666) = 5 openat(AT_FDCWD, "/usr/share/zoneinfo/UTC", O_RDONLY|O_CLOEXEC) = 8 newfstatat(8, "", {st_mode=S_IFREG|0644, st_size=114, ...}, AT_EMPTY_PATH) = 0 newfstatat(8, "", {st_mode=S_IFREG|0644, st_size=114, ...}, AT_EMPTY_PATH) = 0 read(8, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 114 lseek(8, -60, SEEK_CUR) = 54 read(8, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 60 close(8) = 0 write(5, "[Trash Info]\nPath=/home/tjmaciei"..., 103) = 103 renameat(AT_FDCWD, "/home/tjmaciei/tst_qfile.moveToTrashOpenFile.fjYRxv", 6, "tst_qfile.moveToTrashOpenFile.fjYRxv") = 0 close(5) = 0 close(6) = 0 close(7) = 0 On subsequent runs: openat(AT_FDCWD, "/home/tjmaciei/.qttest/share/Trash", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 5 newfstatat(5, "", {st_mode=S_IFDIR|0700, st_size=18, ...}, AT_EMPTY_PATH) = 0 getuid() = 1000 openat(5, "files", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 6 openat(5, "info", O_RDONLY|O_NOFOLLOW|O_CLOEXEC|O_DIRECTORY) = 7 close(5) = 0 openat(7, "tst_qfile.moveToTrashOpenFile.sPjrcA.trashinfo", O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0666) = 5 openat(AT_FDCWD, "/usr/share/zoneinfo/UTC", O_RDONLY|O_CLOEXEC) = 8 newfstatat(8, "", {st_mode=S_IFREG|0644, st_size=114, ...}, AT_EMPTY_PATH) = 0 newfstatat(8, "", {st_mode=S_IFREG|0644, st_size=114, ...}, AT_EMPTY_PATH) = 0 read(8, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 114 lseek(8, -60, SEEK_CUR) = 54 read(8, "TZif2\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 4096) = 60 close(8) = 0 write(5, "[Trash Info]\nPath=/home/tjmaciei"..., 103) = 103 renameat(AT_FDCWD, "/home/tjmaciei/tst_qfile.moveToTrashOpenFile.sPjrcA", 6, "tst_qfile.moveToTrashOpenFile.sPjrcA") = 0 close(5) = 0 close(6) = 0 close(7) = 0 Change-Id: I9d43e5b91eb142d6945cfffd1787117927650dab Reviewed-by: Ahmad Samir <a.samirh78@gmail.com>
This commit is contained in:
parent
25b1990784
commit
c94bed69b7
@ -1180,7 +1180,7 @@ bool QFileSystemEngine::createLink(const QFileSystemEntry &source, const QFileSy
|
||||
|
||||
#ifdef Q_OS_DARWIN
|
||||
// see qfilesystemengine_mac.mm
|
||||
#elif defined(QT_BOOTSTRAPPED)
|
||||
#elif defined(QT_BOOTSTRAPPED) || !defined(AT_FDCWD)
|
||||
// bootstrapped tools don't need this, and we don't want QStorageInfo
|
||||
//static
|
||||
bool QFileSystemEngine::moveFileToTrash(const QFileSystemEntry &, QFileSystemEntry &,
|
||||
@ -1197,29 +1197,49 @@ bool QFileSystemEngine::moveFileToTrash(const QFileSystemEntry &, QFileSystemEnt
|
||||
namespace {
|
||||
struct FreeDesktopTrashOperation
|
||||
{
|
||||
QString infoDir;
|
||||
QFileSystemEntry infoFilePath;
|
||||
int infoFileFd = -1;
|
||||
/*
|
||||
"A trash directory contains two subdirectories, named info and files."
|
||||
*/
|
||||
QString trashPath;
|
||||
int filesDirFd = -1;
|
||||
int infoDirFd = -1;
|
||||
qsizetype volumePrefixLength = 0;
|
||||
|
||||
// relative to infoDirFd from above
|
||||
QByteArray infoFilePath;
|
||||
int infoFileFd = -1; // if we've already opened it
|
||||
|
||||
~FreeDesktopTrashOperation()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
constexpr bool isTrashDirOpen() const { return filesDirFd != -1 && infoDirFd != -1; }
|
||||
|
||||
void close()
|
||||
{
|
||||
int savedErrno = errno;
|
||||
if (infoFileFd != -1) {
|
||||
Q_ASSERT(infoDirFd != -1);
|
||||
Q_ASSERT(!infoFilePath.isEmpty());
|
||||
Q_ASSERT(!trashPath.isEmpty());
|
||||
|
||||
QT_CLOSE(infoFileFd);
|
||||
QSystemError ignoredError;
|
||||
QFileSystemEngine::removeFile(infoFilePath, ignoredError);
|
||||
unlinkat(infoDirFd, infoFilePath, 0);
|
||||
infoFileFd = -1;
|
||||
}
|
||||
if (filesDirFd >= 0)
|
||||
QT_CLOSE(filesDirFd);
|
||||
if (infoDirFd >= 0)
|
||||
QT_CLOSE(infoDirFd);
|
||||
filesDirFd = infoDirFd = -1;
|
||||
errno = savedErrno;
|
||||
}
|
||||
|
||||
bool tryCreateInfoFile(const QString &filePath, QSystemError &error)
|
||||
{
|
||||
Q_ASSERT(filePath.startsWith(u'/'));
|
||||
QFileSystemEntry p(infoDir + filePath + ".trashinfo"_L1);
|
||||
infoFileFd = QT_OPEN(p.nativeFilePath(), QT_OPEN_RDWR | QT_OPEN_CREAT | QT_OPEN_EXCL, 0666);
|
||||
QByteArray p = QFile::encodeName(filePath) + ".trashinfo";
|
||||
infoFileFd = qt_safe_openat(infoDirFd, p, QT_OPEN_RDWR | QT_OPEN_CREAT | QT_OPEN_EXCL, 0666);
|
||||
if (infoFileFd < 0) {
|
||||
error = QSystemError(errno, QSystemError::StandardLibraryError);
|
||||
return false;
|
||||
@ -1233,31 +1253,67 @@ struct FreeDesktopTrashOperation
|
||||
QT_CLOSE(infoFileFd);
|
||||
infoFileFd = -1;
|
||||
}
|
||||
|
||||
// opens a directory and returns the file descriptor
|
||||
static int openDirFd(int dfd, const char *path, int mode = 0)
|
||||
{
|
||||
mode |= QT_OPEN_RDONLY | O_NOFOLLOW | O_DIRECTORY;
|
||||
return qt_safe_openat(dfd, path, mode);
|
||||
}
|
||||
|
||||
// opens an XDG Trash directory that is a subdirectory of dfd, creating if necessary
|
||||
static int openOrCreateDir(int dfd, const char *path)
|
||||
{
|
||||
// try to open it as a dir, first
|
||||
int fd = openDirFd(dfd, path);
|
||||
if (fd >= 0 || errno != ENOENT)
|
||||
return fd;
|
||||
|
||||
// try to mkdirat
|
||||
if (mkdirat(dfd, path, 0700) < 0)
|
||||
return -1;
|
||||
|
||||
// try to open it again
|
||||
return openDirFd(dfd, path);
|
||||
}
|
||||
|
||||
// opens or makes the XDG Trash hierarchy on parentfd (may be -1) called targetDir
|
||||
bool getTrashDir(int parentfd, QString targetDir, QSystemError &error)
|
||||
{
|
||||
if (parentfd == AT_FDCWD)
|
||||
trashPath = targetDir;
|
||||
QByteArray nativePath = QFile::encodeName(targetDir);
|
||||
|
||||
// open the directory
|
||||
int trashfd = openOrCreateDir(parentfd, nativePath);
|
||||
if (trashfd < 0 && errno != ENOENT) {
|
||||
error = QSystemError(errno, QSystemError::StandardLibraryError);
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if it is ours (even if we've just mkdirat'ed it)
|
||||
if (QT_STATBUF st; QT_FSTAT(trashfd, &st) < 0) {
|
||||
return false;
|
||||
} else if (st.st_uid != getuid()) {
|
||||
error = QSystemError(EPERM, QSystemError::StandardLibraryError);
|
||||
return false;
|
||||
}
|
||||
|
||||
filesDirFd = openOrCreateDir(trashfd, "files");
|
||||
if (filesDirFd >= 0)
|
||||
infoDirFd = openOrCreateDir(trashfd, "info");
|
||||
error = QSystemError(errno, QSystemError::StandardLibraryError);
|
||||
if (infoDirFd < 0)
|
||||
close();
|
||||
QT_CLOSE(trashfd);
|
||||
return infoDirFd >= 0;
|
||||
}
|
||||
|
||||
bool findTrashFor(const QFileSystemEntry &source, QSystemError &error);
|
||||
};
|
||||
|
||||
static auto freeDesktopTrashLocation(const QFileSystemEntry &source, QSystemError &error)
|
||||
bool FreeDesktopTrashOperation::findTrashFor(const QFileSystemEntry &source, QSystemError &error)
|
||||
{
|
||||
auto makeTrashDir = [](const QDir &topDir, const QString &trashDir, QSystemError &error) {
|
||||
auto ownerPerms = QFileDevice::ReadOwner
|
||||
| QFileDevice::WriteOwner
|
||||
| QFileDevice::ExeOwner;
|
||||
QString targetDir = topDir.filePath(trashDir);
|
||||
// deliberately not using mkpath, since we want to fail if topDir doesn't exist
|
||||
bool created = QFileSystemEngine::createDirectory(QFileSystemEntry(targetDir), false, ownerPerms);
|
||||
if (created)
|
||||
return targetDir;
|
||||
error = QSystemError(errno, QSystemError::StandardLibraryError);
|
||||
|
||||
// maybe it already exists and is a directory
|
||||
if (QFileInfo(targetDir).isDir())
|
||||
return targetDir;
|
||||
return QString();
|
||||
};
|
||||
struct R {
|
||||
QString trashDir;
|
||||
qsizetype volumePrefixLength = 0;
|
||||
} r;
|
||||
|
||||
// first, check if they are in the same device
|
||||
QString homePath = QFileSystemEngine::homePath();
|
||||
const QString sourcePath = source.filePath();
|
||||
@ -1265,7 +1321,7 @@ static auto freeDesktopTrashLocation(const QFileSystemEntry &source, QSystemErro
|
||||
if (QT_STAT(QFile::encodeName(sourcePath), &sourceInfo) != 0 ||
|
||||
QT_STAT(QFile::encodeName(homePath), &homeInfo) != 0) {
|
||||
error = QSystemError(errno, QSystemError::StandardLibraryError);
|
||||
return r;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStorageInfo sourceStorage(sourcePath);
|
||||
@ -1276,8 +1332,6 @@ static auto freeDesktopTrashLocation(const QFileSystemEntry &source, QSystemErro
|
||||
isHomeVolume = sourceStorage == QStorageInfo(QFileSystemEngine::homePath());
|
||||
}
|
||||
|
||||
QString &trash = r.trashDir;
|
||||
const QStorageInfo homeStorage(QDir::home());
|
||||
// We support trashing of files outside the users home partition
|
||||
if (!isHomeVolume) {
|
||||
const auto dotTrash = "/.Trash"_L1;
|
||||
@ -1295,20 +1349,27 @@ static auto freeDesktopTrashLocation(const QFileSystemEntry &source, QSystemErro
|
||||
*/
|
||||
|
||||
const QString userID = QString::number(::getuid());
|
||||
if (QT_STATBUF st; QT_LSTAT(dotTrashDir.nativeFilePath(), &st) == 0) {
|
||||
// we MUST check that the sticky bit is set, and that it is not a symlink
|
||||
if (S_ISLNK(st.st_mode)) {
|
||||
|
||||
// we MUST check that the sticky bit is set, and that it is not a symlink
|
||||
int genericTrashFd = openDirFd(AT_FDCWD, dotTrashDir.nativeFilePath());
|
||||
QT_STATBUF st = {};
|
||||
if (genericTrashFd < 0 && errno != ENOENT && errno != EACCES) {
|
||||
// O_DIRECTORY + O_NOFOLLOW produces ENOTDIR on Linux
|
||||
if (QT_LSTAT(dotTrashDir.nativeFilePath(), &st) == 0 && S_ISLNK(st.st_mode)) {
|
||||
// we SHOULD report the failed check to the administrator
|
||||
qCritical("Warning: '%s' is a symlink to '%s'",
|
||||
dotTrashDir.nativeFilePath().constData(),
|
||||
qt_readlink(dotTrashDir.nativeFilePath()).constData());
|
||||
error = QSystemError(ELOOP, QSystemError::StandardLibraryError);
|
||||
} else if ((st.st_mode & S_ISVTX) == 0) {
|
||||
}
|
||||
} else if (genericTrashFd >= 0) {
|
||||
QT_FSTAT(genericTrashFd, &st);
|
||||
if ((st.st_mode & S_ISVTX) == 0) {
|
||||
// we SHOULD report the failed check to the administrator
|
||||
qCritical("Warning: '%s' doesn't have sticky bit set!",
|
||||
dotTrashDir.nativeFilePath().constData());
|
||||
error = QSystemError(EPERM, QSystemError::StandardLibraryError);
|
||||
} else if (S_ISDIR(st.st_mode)) {
|
||||
} else {
|
||||
/*
|
||||
"If the directory exists and passes the checks, a subdirectory of the
|
||||
$topdir/.Trash directory is to be used as the user's trash directory
|
||||
@ -1318,9 +1379,14 @@ static auto freeDesktopTrashLocation(const QFileSystemEntry &source, QSystemErro
|
||||
the implementation MUST immediately create it, without any warnings or
|
||||
delays for the user."
|
||||
*/
|
||||
trash = makeTrashDir(dotTrashDir.filePath(), userID, error);
|
||||
if (getTrashDir(genericTrashFd, userID, error)) {
|
||||
// recreate the resulting path
|
||||
trashPath = dotTrashDir.filePath() + u'/' + userID;
|
||||
}
|
||||
}
|
||||
QT_CLOSE(genericTrashFd);
|
||||
}
|
||||
|
||||
/*
|
||||
Method 2:
|
||||
"If an $topdir/.Trash directory is absent, an $topdir/.Trash-$uid directory is to be
|
||||
@ -1328,19 +1394,18 @@ static auto freeDesktopTrashLocation(const QFileSystemEntry &source, QSystemErro
|
||||
file, if an $topdir/.Trash-$uid directory does not exist, the implementation MUST
|
||||
immediately create it, without any warnings or delays for the user."
|
||||
*/
|
||||
if (trash.isEmpty()) {
|
||||
const QString userTrashDir = dotTrash + u'-' + userID;
|
||||
trash = makeTrashDir(QDir(sourceStorage.rootPath() + userTrashDir), QString(), error);
|
||||
}
|
||||
if (!isTrashDirOpen())
|
||||
getTrashDir(AT_FDCWD, sourceStorage.rootPath() + dotTrash + u'-' + userID, error);
|
||||
|
||||
if (!trash.isEmpty()) {
|
||||
r.volumePrefixLength = sourceStorage.rootPath().size();
|
||||
if (r.volumePrefixLength == 1)
|
||||
r.volumePrefixLength = 0; // isRoot
|
||||
if (isTrashDirOpen()) {
|
||||
volumePrefixLength = sourceStorage.rootPath().size();
|
||||
if (volumePrefixLength == 1)
|
||||
volumePrefixLength = 0; // isRoot
|
||||
else
|
||||
++r.volumePrefixLength; // to include the slash
|
||||
++volumePrefixLength; // to include the slash
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
"If both (1) and (2) fail [...], the implementation MUST either trash the
|
||||
file into the user's “home trash” or refuse to trash it."
|
||||
@ -1351,16 +1416,13 @@ static auto freeDesktopTrashLocation(const QFileSystemEntry &source, QSystemErro
|
||||
QStandardPaths returns for GenericDataLocation. If that doesn't exist, then
|
||||
we are not running on a freedesktop.org-compliant environment, and give up.
|
||||
*/
|
||||
if (trash.isEmpty()) {
|
||||
QDir topDir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
|
||||
trash = makeTrashDir(topDir, "Trash"_L1, error);
|
||||
if (trash.isEmpty()) {
|
||||
qWarning("Unable to establish trash directory in %s",
|
||||
topDir.path().toLocal8Bit().constData());
|
||||
}
|
||||
if (!isTrashDirOpen()) {
|
||||
QString topDir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
|
||||
if (!getTrashDir(AT_FDCWD, topDir + "/Trash"_L1, error))
|
||||
qWarning("Unable to establish trash directory in %ls", qUtf16Printable(topDir));
|
||||
}
|
||||
|
||||
return r;
|
||||
return isTrashDirOpen();
|
||||
}
|
||||
} // unnamed namespace
|
||||
|
||||
@ -1375,26 +1437,9 @@ bool QFileSystemEngine::moveFileToTrash(const QFileSystemEntry &source,
|
||||
}
|
||||
return absoluteName(source);
|
||||
}();
|
||||
auto [trashPath, volumePrefixLength] = freeDesktopTrashLocation(sourcePath, error);
|
||||
FreeDesktopTrashOperation op;
|
||||
if (trashPath.isEmpty())
|
||||
if (!op.findTrashFor(sourcePath, error))
|
||||
return false;
|
||||
QDir trashDir(trashPath);
|
||||
/*
|
||||
"A trash directory contains two subdirectories, named info and files."
|
||||
*/
|
||||
const auto filesDir = "files"_L1;
|
||||
const auto infoDir = "info"_L1;
|
||||
trashDir.mkdir(filesDir);
|
||||
int savedErrno = errno;
|
||||
trashDir.mkdir(infoDir);
|
||||
if (!savedErrno)
|
||||
savedErrno = errno;
|
||||
if (!trashDir.exists(filesDir) || !trashDir.exists(infoDir)) {
|
||||
error = QSystemError(savedErrno, QSystemError::StandardLibraryError);
|
||||
return false;
|
||||
}
|
||||
op.infoDir = trashDir.filePath(infoDir);
|
||||
|
||||
/*
|
||||
"The $trash/files directory contains the files and directories that were trashed.
|
||||
@ -1416,7 +1461,7 @@ bool QFileSystemEngine::moveFileToTrash(const QFileSystemEntry &source,
|
||||
filename, and then opening with O_EXCL. If that succeeds the creation was atomic
|
||||
(at least on the same machine), if it fails you need to pick another filename."
|
||||
*/
|
||||
QString uniqueTrashedName = u'/' + sourcePath.fileName();
|
||||
QString uniqueTrashedName = sourcePath.fileName();
|
||||
if (!op.tryCreateInfoFile(uniqueTrashedName, error) && error.errorCode == EEXIST) {
|
||||
// we'll use a counter, starting with the file's inode number to avoid
|
||||
// collisions
|
||||
@ -1441,24 +1486,28 @@ bool QFileSystemEngine::moveFileToTrash(const QFileSystemEntry &source,
|
||||
|
||||
QByteArray info =
|
||||
"[Trash Info]\n"
|
||||
"Path=" + QUrl::toPercentEncoding(sourcePath.filePath().mid(volumePrefixLength), "/") + "\n"
|
||||
"Path=" + QUrl::toPercentEncoding(source.filePath().mid(op.volumePrefixLength), "/") + "\n"
|
||||
"DeletionDate=" + QDateTime::currentDateTime().toString(Qt::ISODate).toUtf8()
|
||||
+ "\n";
|
||||
if (QT_WRITE(op.infoFileFd, info.data(), info.size()) < 0) {
|
||||
error = QSystemError(errno, QSystemError::StandardLibraryError);
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
We might fail to rename if source and target are on different file systems.
|
||||
In that case, we don't try further, i.e. copying and removing the original
|
||||
is usually not what the user would expect to happen.
|
||||
*/
|
||||
QFileSystemEntry target(trashDir.filePath(filesDir) + uniqueTrashedName);
|
||||
if (!renameFile(source, target, error))
|
||||
bool renamed = renameat(AT_FDCWD, source.nativeFilePath(), op.filesDirFd,
|
||||
QFile::encodeName(uniqueTrashedName)) == 0;
|
||||
if (!renamed) {
|
||||
error = QSystemError(errno, QSystemError::StandardLibraryError);
|
||||
return false;
|
||||
}
|
||||
|
||||
op.commit();
|
||||
newLocation = std::move(target);
|
||||
newLocation = QFileSystemEntry(op.trashPath + "/files/"_L1 + uniqueTrashedName);
|
||||
return true;
|
||||
}
|
||||
#endif // !Q_OS_DARWIN && !QT_BOOTSTRAPPED
|
||||
|
@ -204,6 +204,16 @@ Q_CORE_EXPORT int qt_open64(const char *pathname, int flags, mode_t);
|
||||
# endif
|
||||
#endif
|
||||
|
||||
#ifdef AT_FDCWD
|
||||
static inline int qt_safe_openat(int dfd, const char *pathname, int flags, mode_t mode = 0777)
|
||||
{
|
||||
// everyone already has O_CLOEXEC
|
||||
int fd;
|
||||
EINTR_LOOP(fd, openat(dfd, pathname, flags | O_CLOEXEC, mode));
|
||||
return fd;
|
||||
}
|
||||
#endif
|
||||
|
||||
// don't call QT_OPEN or ::open
|
||||
// call qt_safe_open
|
||||
static inline int qt_safe_open(const char *pathname, int flags, mode_t mode = 0777)
|
||||
|
@ -4289,7 +4289,6 @@ void tst_QFile::moveToTrashXdgSafety()
|
||||
// ditto for our user's subdir now
|
||||
chmod(QFile::encodeName(genericTrashDir.path()), 01755);
|
||||
genericTrashDir.mkdir(QString::number(getuid()), QFile::ReadOwner);
|
||||
QEXPECT_FAIL("", "Fall back not working", Continue);
|
||||
QVERIFY(tryTrashing());
|
||||
}
|
||||
#endif
|
||||
|
Loading…
x
Reference in New Issue
Block a user