add support for backup/restore save to usb
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "fs.hpp"
|
#include "fs.hpp"
|
||||||
|
#include "location.hpp"
|
||||||
#include <switch.h>
|
#include <switch.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@@ -30,6 +31,17 @@ enum DumpLocationFlag {
|
|||||||
DumpLocationFlag_All = DumpLocationFlag_SdCard | DumpLocationFlag_UsbS2S | DumpLocationFlag_DevNull | DumpLocationFlag_Stdio | DumpLocationFlag_Network,
|
DumpLocationFlag_All = DumpLocationFlag_SdCard | DumpLocationFlag_UsbS2S | DumpLocationFlag_DevNull | DumpLocationFlag_Stdio | DumpLocationFlag_Network,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct DumpEntry {
|
||||||
|
DumpLocationType type;
|
||||||
|
s32 index;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DumpLocation {
|
||||||
|
DumpEntry entry{};
|
||||||
|
location::Entries network{};
|
||||||
|
location::StdioEntries stdio{};
|
||||||
|
};
|
||||||
|
|
||||||
struct BaseSource {
|
struct BaseSource {
|
||||||
virtual ~BaseSource() = default;
|
virtual ~BaseSource() = default;
|
||||||
virtual Result Read(const std::string& path, void* buf, s64 off, s64 size, u64* bytes_read) = 0;
|
virtual Result Read(const std::string& path, void* buf, s64 off, s64 size, u64* bytes_read) = 0;
|
||||||
@@ -40,7 +52,13 @@ struct BaseSource {
|
|||||||
|
|
||||||
// called after dump has finished.
|
// called after dump has finished.
|
||||||
using OnExit = std::function<void(Result rc)>;
|
using OnExit = std::function<void(Result rc)>;
|
||||||
|
using OnLocation = std::function<void(const DumpLocation& loc)>;
|
||||||
|
|
||||||
|
// prompts the user to select dump location, calls on_loc on success with the selected location.
|
||||||
|
void DumpGetLocation(const std::string& title, u32 location_flags, OnLocation on_loc);
|
||||||
|
// dumps to a fetched location using DumpGetLocation().
|
||||||
|
void Dump(std::shared_ptr<BaseSource> source, const DumpLocation& location, const std::vector<fs::FsPath>& paths, OnExit on_exit);
|
||||||
|
// DumpGetLocation() + Dump() all in one.
|
||||||
void Dump(std::shared_ptr<BaseSource> source, const std::vector<fs::FsPath>& paths, OnExit on_exit = [](Result){}, u32 location_flags = DumpLocationFlag_All);
|
void Dump(std::shared_ptr<BaseSource> source, const std::vector<fs::FsPath>& paths, OnExit on_exit = [](Result){}, u32 location_flags = DumpLocationFlag_All);
|
||||||
|
|
||||||
} // namespace sphaira::dump
|
} // namespace sphaira::dump
|
||||||
|
|||||||
@@ -23,17 +23,12 @@
|
|||||||
namespace sphaira::dump {
|
namespace sphaira::dump {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
struct DumpEntry {
|
struct DumpLocationEntry {
|
||||||
DumpLocationType type;
|
|
||||||
s32 index;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct DumpLocation {
|
|
||||||
const DumpLocationType type;
|
const DumpLocationType type;
|
||||||
const char* name;
|
const char* name;
|
||||||
};
|
};
|
||||||
|
|
||||||
constexpr DumpLocation DUMP_LOCATIONS[]{
|
constexpr DumpLocationEntry DUMP_LOCATIONS[]{
|
||||||
{ DumpLocationType_SdCard, "microSD card (/dumps/)" },
|
{ DumpLocationType_SdCard, "microSD card (/dumps/)" },
|
||||||
{ DumpLocationType_UsbS2S, "USB transfer (Switch 2 Switch)" },
|
{ DumpLocationType_UsbS2S, "USB transfer (Switch 2 Switch)" },
|
||||||
{ DumpLocationType_DevNull, "/dev/null (Speed Test)" },
|
{ DumpLocationType_DevNull, "/dev/null (Speed Test)" },
|
||||||
@@ -319,23 +314,24 @@ Result DumpToNetwork(ui::ProgressBox* pbox, const location::Entry& loc, BaseSour
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void Dump(std::shared_ptr<BaseSource> source, const std::vector<fs::FsPath>& paths, OnExit on_exit, u32 location_flags) {
|
void DumpGetLocation(const std::string& title, u32 location_flags, OnLocation on_loc) {
|
||||||
|
DumpLocation out;
|
||||||
ui::PopupList::Items items;
|
ui::PopupList::Items items;
|
||||||
std::vector<DumpEntry> dump_entries;
|
std::vector<DumpEntry> dump_entries;
|
||||||
|
|
||||||
const auto network_locations = location::Load();
|
out.network = location::Load();
|
||||||
if (location_flags & (1 << DumpLocationType_Network)) {
|
if (location_flags & (1 << DumpLocationType_Network)) {
|
||||||
for (s32 i = 0; i < std::size(network_locations); i++) {
|
for (s32 i = 0; i < std::size(out.network); i++) {
|
||||||
dump_entries.emplace_back(DumpLocationType_Network, i);
|
dump_entries.emplace_back(DumpLocationType_Network, i);
|
||||||
items.emplace_back(network_locations[i].name);
|
items.emplace_back(out.network[i].name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto stdio_locations = location::GetStdio(true);
|
out.stdio = location::GetStdio(true);
|
||||||
if (location_flags & (1 << DumpLocationType_Stdio)) {
|
if (location_flags & (1 << DumpLocationType_Stdio)) {
|
||||||
for (s32 i = 0; i < std::size(stdio_locations); i++) {
|
for (s32 i = 0; i < std::size(out.stdio); i++) {
|
||||||
dump_entries.emplace_back(DumpLocationType_Stdio, i);
|
dump_entries.emplace_back(DumpLocationType_Stdio, i);
|
||||||
items.emplace_back(stdio_locations[i].name);
|
items.emplace_back(out.stdio[i].name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,24 +343,24 @@ void Dump(std::shared_ptr<BaseSource> source, const std::vector<fs::FsPath>& pat
|
|||||||
}
|
}
|
||||||
|
|
||||||
App::Push(std::make_shared<ui::PopupList>(
|
App::Push(std::make_shared<ui::PopupList>(
|
||||||
"Select dump location"_i18n, items, [source, paths, on_exit, network_locations, stdio_locations, dump_entries](auto op_index){
|
title, items, [dump_entries, out, on_loc](auto op_index) mutable {
|
||||||
if (!op_index) {
|
out.entry = dump_entries[*op_index];
|
||||||
on_exit(0xFFFF);
|
on_loc(out);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
const auto dump_entry = dump_entries[*op_index];
|
void Dump(std::shared_ptr<BaseSource> source, const DumpLocation& location, const std::vector<fs::FsPath>& paths, OnExit on_exit) {
|
||||||
|
App::Push(std::make_shared<ui::ProgressBox>(0, "Dumping"_i18n, "", [source, paths, location](auto pbox) -> Result {
|
||||||
App::Push(std::make_shared<ui::ProgressBox>(0, "Dumping"_i18n, "", [source, paths, network_locations, stdio_locations, dump_entry](auto pbox) -> Result {
|
if (location.entry.type == DumpLocationType_Network) {
|
||||||
if (dump_entry.type == DumpLocationType_Network) {
|
R_TRY(DumpToNetwork(pbox, location.network[location.entry.index], source.get(), paths));
|
||||||
R_TRY(DumpToNetwork(pbox, network_locations[dump_entry.index], source.get(), paths));
|
} else if (location.entry.type == DumpLocationType_Stdio) {
|
||||||
} else if (dump_entry.type == DumpLocationType_Stdio) {
|
R_TRY(DumpToStdio(pbox, location.stdio[location.entry.index], source.get(), paths));
|
||||||
R_TRY(DumpToStdio(pbox, stdio_locations[dump_entry.index], source.get(), paths));
|
} else if (location.entry.type == DumpLocationType_SdCard) {
|
||||||
} else if (dump_entry.type == DumpLocationType_SdCard) {
|
|
||||||
R_TRY(DumpToFileNative(pbox, source.get(), paths));
|
R_TRY(DumpToFileNative(pbox, source.get(), paths));
|
||||||
} else if (dump_entry.type == DumpLocationType_UsbS2S) {
|
} else if (location.entry.type == DumpLocationType_UsbS2S) {
|
||||||
R_TRY(DumpToUsbS2S(pbox, source.get(), paths));
|
R_TRY(DumpToUsbS2S(pbox, source.get(), paths));
|
||||||
} else if (dump_entry.type == DumpLocationType_DevNull) {
|
} else if (location.entry.type == DumpLocationType_DevNull) {
|
||||||
R_TRY(DumpToDevNull(pbox, source.get(), paths));
|
R_TRY(DumpToDevNull(pbox, source.get(), paths));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,8 +375,12 @@ void Dump(std::shared_ptr<BaseSource> source, const std::vector<fs::FsPath>& pat
|
|||||||
|
|
||||||
on_exit(rc);
|
on_exit(rc);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
));
|
|
||||||
|
void Dump(std::shared_ptr<BaseSource> source, const std::vector<fs::FsPath>& paths, OnExit on_exit, u32 location_flags) {
|
||||||
|
DumpGetLocation("Select dump location"_i18n, location_flags, [source, paths, on_exit](const DumpLocation& loc){
|
||||||
|
Dump(source, loc, paths, on_exit);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace sphaira::dump
|
} // namespace sphaira::dump
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include "image.hpp"
|
#include "image.hpp"
|
||||||
#include "threaded_file_transfer.hpp"
|
#include "threaded_file_transfer.hpp"
|
||||||
#include "minizip_helper.hpp"
|
#include "minizip_helper.hpp"
|
||||||
|
#include "dumper.hpp"
|
||||||
|
|
||||||
#include "ui/menus/save_menu.hpp"
|
#include "ui/menus/save_menu.hpp"
|
||||||
#include "ui/menus/filebrowser.hpp"
|
#include "ui/menus/filebrowser.hpp"
|
||||||
@@ -504,7 +505,16 @@ Result RestoreSaveInternal(ProgressBox* pbox, const Entry& e, const fs::FsPath&
|
|||||||
R_SUCCEED();
|
R_SUCCEED();
|
||||||
}
|
}
|
||||||
|
|
||||||
Result BackupSaveInternal(ProgressBox* pbox, const AccountEntry& acc, const Entry& e, bool compressed, bool is_auto = false) {
|
Result BackupSaveInternal(ProgressBox* pbox, const dump::DumpLocation& location, const AccountEntry& acc, const Entry& e, bool compressed, bool is_auto = false) {
|
||||||
|
std::unique_ptr<fs::Fs> fs;
|
||||||
|
if (location.entry.type == dump::DumpLocationType_Stdio) {
|
||||||
|
fs = std::make_unique<fs::FsStdio>(true, location.stdio[location.entry.index].mount);
|
||||||
|
} else if (location.entry.type == dump::DumpLocationType_SdCard) {
|
||||||
|
fs = std::make_unique<fs::FsNativeSd>();
|
||||||
|
} else {
|
||||||
|
R_THROW(0x1);
|
||||||
|
}
|
||||||
|
|
||||||
pbox->SetTitle(e.GetName());
|
pbox->SetTitle(e.GetName());
|
||||||
if (e.image) {
|
if (e.image) {
|
||||||
pbox->SetImage(e.image);
|
pbox->SetImage(e.image);
|
||||||
@@ -539,10 +549,6 @@ Result BackupSaveInternal(ProgressBox* pbox, const AccountEntry& acc, const Entr
|
|||||||
// the save file may be empty, this isn't an error, but we exit early.
|
// the save file may be empty, this isn't an error, but we exit early.
|
||||||
R_UNLESS(!collections.empty(), 0x0);
|
R_UNLESS(!collections.empty(), 0x0);
|
||||||
|
|
||||||
// we will actually store this to the dump locations, eventually.
|
|
||||||
fs::FsNativeSd fs;
|
|
||||||
R_TRY(fs.GetFsOpenResult());
|
|
||||||
|
|
||||||
const auto t = std::time(NULL);
|
const auto t = std::time(NULL);
|
||||||
const auto tm = std::localtime(&t);
|
const auto tm = std::localtime(&t);
|
||||||
|
|
||||||
@@ -555,11 +561,11 @@ Result BackupSaveInternal(ProgressBox* pbox, const AccountEntry& acc, const Entr
|
|||||||
zip_info_default.tmz_date.tm_mon = tm->tm_mon;
|
zip_info_default.tmz_date.tm_mon = tm->tm_mon;
|
||||||
zip_info_default.tmz_date.tm_year = tm->tm_year;
|
zip_info_default.tmz_date.tm_year = tm->tm_year;
|
||||||
|
|
||||||
const auto path = BuildSavePath(acc, e, is_auto);
|
const auto path = fs::AppendPath(fs->Root(), BuildSavePath(acc, e, is_auto));
|
||||||
const auto temp_path = path + ".temp";
|
const auto temp_path = path + ".temp";
|
||||||
|
|
||||||
fs.CreateDirectoryRecursivelyWithPath(temp_path);
|
fs->CreateDirectoryRecursivelyWithPath(temp_path);
|
||||||
ON_SCOPE_EXIT(fs.DeleteFile(temp_path));
|
ON_SCOPE_EXIT(fs->DeleteFile(temp_path));
|
||||||
|
|
||||||
// zip to memory if less than 1GB and not applet mode.
|
// zip to memory if less than 1GB and not applet mode.
|
||||||
// TODO: use my mmz code from ftpsrv to stream zip creation.
|
// TODO: use my mmz code from ftpsrv to stream zip creation.
|
||||||
@@ -606,9 +612,9 @@ Result BackupSaveInternal(ProgressBox* pbox, const AccountEntry& acc, const Entr
|
|||||||
|
|
||||||
// try and load the actual timestamp of the file.
|
// try and load the actual timestamp of the file.
|
||||||
// TODO: not supported for saves...
|
// TODO: not supported for saves...
|
||||||
#if 0
|
#if 1
|
||||||
FsTimeStampRaw timestamp{};
|
FsTimeStampRaw timestamp{};
|
||||||
if (R_SUCCEEDED(fs.GetFileTimeStampRaw(file_path, ×tamp)) && timestamp.is_valid) {
|
if (R_SUCCEEDED(save_fs.GetFileTimeStampRaw(file_path, ×tamp)) && timestamp.is_valid) {
|
||||||
const auto time = (time_t)timestamp.modified;
|
const auto time = (time_t)timestamp.modified;
|
||||||
if (auto tm = localtime(&time)) {
|
if (auto tm = localtime(&time)) {
|
||||||
zip_info.tmz_date.tm_sec = tm->tm_sec;
|
zip_info.tmz_date.tm_sec = tm->tm_sec;
|
||||||
@@ -660,10 +666,10 @@ Result BackupSaveInternal(ProgressBox* pbox, const AccountEntry& acc, const Entr
|
|||||||
const auto is_file_based_emummc = App::IsFileBaseEmummc();
|
const auto is_file_based_emummc = App::IsFileBaseEmummc();
|
||||||
if (!file_download) {
|
if (!file_download) {
|
||||||
pbox->NewTransfer("Flushing zip to file");
|
pbox->NewTransfer("Flushing zip to file");
|
||||||
R_TRY(fs.CreateFile(temp_path, mz_mem.buf.size(), 0));
|
R_TRY(fs->CreateFile(temp_path, mz_mem.buf.size(), 0));
|
||||||
|
|
||||||
fs::File file;
|
fs::File file;
|
||||||
R_TRY(fs.OpenFile(temp_path, FsOpenMode_Write, &file));
|
R_TRY(fs->OpenFile(temp_path, FsOpenMode_Write, &file));
|
||||||
|
|
||||||
R_TRY(thread::Transfer(pbox, mz_mem.buf.size(),
|
R_TRY(thread::Transfer(pbox, mz_mem.buf.size(),
|
||||||
[&](void* data, s64 off, s64 size, u64* bytes_read) -> Result {
|
[&](void* data, s64 off, s64 size, u64* bytes_read) -> Result {
|
||||||
@@ -682,10 +688,9 @@ Result BackupSaveInternal(ProgressBox* pbox, const AccountEntry& acc, const Entr
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.DeleteFile(path);
|
fs->DeleteFile(path);
|
||||||
R_TRY(fs.RenameFile(temp_path, path));
|
R_TRY(fs->RenameFile(temp_path, path));
|
||||||
|
|
||||||
App::Notify("Backed up to "_i18n + path.toString());
|
|
||||||
R_SUCCEED();
|
R_SUCCEED();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,20 +1179,12 @@ void Menu::OnLayoutChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Menu::BackupSaves(std::vector<std::reference_wrapper<Entry>>& entries) {
|
void Menu::BackupSaves(std::vector<std::reference_wrapper<Entry>>& entries) {
|
||||||
int image = 0;
|
dump::DumpGetLocation("Select backup location"_i18n, dump::DumpLocationFlag_SdCard|dump::DumpLocationFlag_Stdio, [this, entries](const dump::DumpLocation& location){
|
||||||
if (entries.size() == 1) {
|
App::Push(std::make_shared<ProgressBox>(0, "Backup"_i18n, "", [this, entries, location](auto pbox) -> Result {
|
||||||
image = entries[0].get().image;
|
|
||||||
}
|
|
||||||
|
|
||||||
App::Push(std::make_shared<OptionBox>(
|
|
||||||
"Are you sure you want to backup save(s)?"_i18n,
|
|
||||||
"Back"_i18n, "Backup"_i18n, 0, [this, entries](auto op_index){
|
|
||||||
if (op_index && *op_index) {
|
|
||||||
App::Push(std::make_shared<ProgressBox>(0, "Backup"_i18n, "", [this, entries](auto pbox) -> Result {
|
|
||||||
for (auto& e : entries) {
|
for (auto& e : entries) {
|
||||||
// the entry may not have loaded yet.
|
// the entry may not have loaded yet.
|
||||||
LoadControlEntry(e);
|
LoadControlEntry(e);
|
||||||
R_TRY(BackupSaveInternal(pbox, m_accounts[m_account_index], e, m_compress_save_backup.Get()));
|
R_TRY(BackupSaveInternal(pbox, location, m_accounts[m_account_index], e, m_compress_save_backup.Get()));
|
||||||
}
|
}
|
||||||
R_SUCCEED();
|
R_SUCCEED();
|
||||||
}, [](Result rc){
|
}, [](Result rc){
|
||||||
@@ -1197,17 +1194,21 @@ void Menu::BackupSaves(std::vector<std::reference_wrapper<Entry>>& entries) {
|
|||||||
App::Notify("Backup successfull!"_i18n);
|
App::Notify("Backup successfull!"_i18n);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
});
|
||||||
}, image
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Menu::RestoreSave() {
|
void Menu::RestoreSave() {
|
||||||
const auto save_path = BuildSaveBasePath(m_entries[m_index]);
|
dump::DumpGetLocation("Select restore location"_i18n, dump::DumpLocationFlag_SdCard|dump::DumpLocationFlag_Stdio, [this](const dump::DumpLocation& location){
|
||||||
|
std::unique_ptr<fs::Fs> fs;
|
||||||
|
if (location.entry.type == dump::DumpLocationType_Stdio) {
|
||||||
|
fs = std::make_unique<fs::FsStdio>(true, location.stdio[location.entry.index].mount);
|
||||||
|
} else if (location.entry.type == dump::DumpLocationType_SdCard) {
|
||||||
|
fs = std::make_unique<fs::FsNativeSd>();
|
||||||
|
}
|
||||||
|
|
||||||
fs::FsNativeSd fs;
|
const auto save_path = fs::AppendPath(fs->Root(), BuildSaveBasePath(m_entries[m_index]));
|
||||||
filebrowser::FsDirCollection collection;
|
filebrowser::FsDirCollection collection;
|
||||||
filebrowser::FsView::get_collection(&fs, save_path, "", collection, true, false, false);
|
filebrowser::FsView::get_collection(fs.get(), save_path, "", collection, true, false, false);
|
||||||
|
|
||||||
// reverse as they will be sorted in oldest -> newest.
|
// reverse as they will be sorted in oldest -> newest.
|
||||||
std::ranges::reverse(collection.files);
|
std::ranges::reverse(collection.files);
|
||||||
@@ -1234,7 +1235,7 @@ void Menu::RestoreSave() {
|
|||||||
|
|
||||||
const auto title = "Restore save for: "_i18n + m_entries[m_index].GetName();
|
const auto title = "Restore save for: "_i18n + m_entries[m_index].GetName();
|
||||||
App::Push(std::make_shared<PopupList>(
|
App::Push(std::make_shared<PopupList>(
|
||||||
title, items, [this, paths, items](auto op_index){
|
title, items, [this, paths, items, location](auto op_index){
|
||||||
if (!op_index) {
|
if (!op_index) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1244,15 +1245,15 @@ void Menu::RestoreSave() {
|
|||||||
|
|
||||||
App::Push(std::make_shared<OptionBox>(
|
App::Push(std::make_shared<OptionBox>(
|
||||||
"Are you sure you want to restore "_i18n + file_name + "?",
|
"Are you sure you want to restore "_i18n + file_name + "?",
|
||||||
"Back"_i18n, "Restore"_i18n, 0, [this, file_path](auto op_index){
|
"Back"_i18n, "Restore"_i18n, 0, [this, file_path, location](auto op_index){
|
||||||
if (op_index && *op_index) {
|
if (op_index && *op_index) {
|
||||||
App::Push(std::make_shared<ProgressBox>(0, "Restore"_i18n, "", [this, file_path](auto pbox) -> Result {
|
App::Push(std::make_shared<ProgressBox>(0, "Restore"_i18n, "", [this, file_path, location](auto pbox) -> Result {
|
||||||
// the entry may not have loaded yet.
|
// the entry may not have loaded yet.
|
||||||
LoadControlEntry(m_entries[m_index]);
|
LoadControlEntry(m_entries[m_index]);
|
||||||
|
|
||||||
if (m_auto_backup_on_restore.Get()) {
|
if (m_auto_backup_on_restore.Get()) {
|
||||||
pbox->SetActionName("Auto backup"_i18n);
|
pbox->SetActionName("Auto backup"_i18n);
|
||||||
R_TRY(BackupSaveInternal(pbox, m_accounts[m_account_index], m_entries[m_index], m_compress_save_backup.Get(), true));
|
R_TRY(BackupSaveInternal(pbox, location, m_accounts[m_account_index], m_entries[m_index], m_compress_save_backup.Get(), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
pbox->SetActionName("Restore"_i18n);
|
pbox->SetActionName("Restore"_i18n);
|
||||||
@@ -1269,6 +1270,7 @@ void Menu::RestoreSave() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace sphaira::ui::menu::save
|
} // namespace sphaira::ui::menu::save
|
||||||
|
|||||||
Reference in New Issue
Block a user