mirror of
https://github.com/VirtualHotBar/NetMount.git
synced 2026-06-09 08:02:20 +08:00
feat(config): 添加配置导入导出功能
- 在设置页面添加数据管理卡片,支持导出配置为ZIP文件和从ZIP导入配置 - 实现后端导出导入逻辑,包括文件验证、临时目录处理和错误处理 - 添加多语言支持,完善相关UI交互和状态提示 - 修复存储添加页面重复提交问题,添加提交中状态 - 修正版本标记文件路径错误
This commit is contained in:
@@ -25,7 +25,7 @@ const DEFAULT_OPENLIST_VERSION: &str = "v4.1.10";
|
||||
// - NETMOUNT_SKIP_TAURI_BUILD: skip tauri_build::try_build to avoid transient Windows file lock issues
|
||||
|
||||
// 版本标记文件
|
||||
const OPENLIST_VERSION_FILE: &str = "binaries/openlist/.version";
|
||||
const OPENLIST_VERSION_FILE: &str = "binaries/openlist.version";
|
||||
|
||||
struct ResBinUrls {
|
||||
rclone: &'static str,
|
||||
|
||||
@@ -687,5 +687,16 @@
|
||||
"project_id": "Project ID",
|
||||
"use_s3_upload_method": "Use S3 Upload Method",
|
||||
"mode": "Mode",
|
||||
"level": "Level"
|
||||
"level": "Level",
|
||||
"saving": "Saving...",
|
||||
"data_management": "Data Management",
|
||||
"export_config": "Export Configuration",
|
||||
"export_config_description": "Package current configuration into a ZIP file (excluding logs)",
|
||||
"export": "Export",
|
||||
"config_exported": "Configuration exported",
|
||||
"import_config": "Import Configuration",
|
||||
"import_config_description": "Restore configuration from ZIP file (requires restart)",
|
||||
"import": "Import",
|
||||
"confirm_import": "Confirm Import",
|
||||
"confirm_import_description": "Importing configuration will overwrite all current settings and automatically restart the software. Continue?"
|
||||
}
|
||||
|
||||
@@ -687,5 +687,16 @@
|
||||
"project_id": "项目ID",
|
||||
"use_s3_upload_method":"使用S3上传方式",
|
||||
"mode": "模式",
|
||||
"level": "等级"
|
||||
"level": "等级",
|
||||
"saving": "保存中...",
|
||||
"data_management": "数据管理",
|
||||
"export_config": "导出配置",
|
||||
"export_config_description": "将当前配置打包为 ZIP 文件(排除日志)",
|
||||
"export": "导出",
|
||||
"config_exported": "配置已导出",
|
||||
"import_config": "导入配置",
|
||||
"import_config_description": "从 ZIP 文件恢复配置(需要重启)",
|
||||
"import": "导入",
|
||||
"confirm_import": "确认导入",
|
||||
"confirm_import_description": "导入配置将覆盖当前所有配置并自动重启软件,是否继续?"
|
||||
}
|
||||
|
||||
@@ -684,8 +684,19 @@
|
||||
"owner": "所有者",
|
||||
"shareUrl": "分享連結",
|
||||
"user_agent": "User-Agent",
|
||||
"project_id": "專案ID",
|
||||
"use_s3_upload_method": "使用S3上傳方式",
|
||||
"project_id": "專案 ID",
|
||||
"use_s3_upload_method": "使用 S3 上傳方式",
|
||||
"mode": "模式",
|
||||
"level": "等級"
|
||||
"level": "等級",
|
||||
"saving": "保存中...",
|
||||
"data_management": "數據管理",
|
||||
"export_config": "導出配置",
|
||||
"export_config_description": "將當前配置打包為 ZIP 文件(排除日誌)",
|
||||
"export": "導出",
|
||||
"config_exported": "配置已導出",
|
||||
"import_config": "導入配置",
|
||||
"import_config_description": "從 ZIP 文件恢復配置(需要重啓)",
|
||||
"import": "導入",
|
||||
"confirm_import": "確認導入",
|
||||
"confirm_import_description": "導入配置將覆蓋當前所有配置並自動重啓軟件,是否繼續?"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Seek, SeekFrom},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
@@ -121,3 +121,290 @@ pub fn copy_file(src: &str, dest: &str) -> Result<(), String> {
|
||||
.map_err(|io_error| format!("Failed to copy file: {}", io_error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 配置导入导出功能
|
||||
// ============================================
|
||||
|
||||
fn resolve_tilde(app: &tauri::AppHandle<Runtime>, path: &str) -> anyhow::Result<PathBuf> {
|
||||
if path.starts_with('~') {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))?;
|
||||
let rest = path
|
||||
.trim_start_matches('~')
|
||||
.trim_start_matches(['/', '\\']);
|
||||
if rest.is_empty() {
|
||||
Ok(home)
|
||||
} else {
|
||||
Ok(home.join(rest))
|
||||
}
|
||||
} else {
|
||||
Ok(Path::new(path).to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证 zip 文件的目录结构
|
||||
fn validate_zip_structure(zip: &mut zip::ZipArchive<impl Read + Seek>) -> anyhow::Result<()> {
|
||||
let required_files = [
|
||||
"config.json",
|
||||
"openlist/config.json",
|
||||
];
|
||||
|
||||
let mut found_files = std::collections::HashSet::new();
|
||||
|
||||
for i in 0..zip.len() {
|
||||
let entry = zip.by_index(i)?;
|
||||
let name = entry.name();
|
||||
found_files.insert(name.to_string());
|
||||
}
|
||||
|
||||
// 检查必需文件是否存在
|
||||
for required in &required_files {
|
||||
if !found_files.contains(*required) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid backup structure: missing required file '{}'",
|
||||
required
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 递归复制目录
|
||||
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> anyhow::Result<()> {
|
||||
let dst = dst.as_ref();
|
||||
fs::create_dir_all(&dst)?;
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let ty = entry.file_type()?;
|
||||
let src_path = entry.path();
|
||||
let dst_path = dst.join(entry.file_name());
|
||||
|
||||
if ty.is_dir() {
|
||||
copy_dir_all(&src_path, &dst_path)?;
|
||||
} else {
|
||||
fs::copy(&src_path, &dst_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导出配置到 zip 文件
|
||||
#[tauri::command]
|
||||
pub fn export_config(
|
||||
app: tauri::AppHandle<Runtime>,
|
||||
out_path: String,
|
||||
) -> anyhow_tauri::TAResult<String> {
|
||||
fn inner(app: &tauri::AppHandle<Runtime>, out_path: &str) -> anyhow::Result<String> {
|
||||
let out_path = out_path.trim();
|
||||
if out_path.is_empty() {
|
||||
return Err(anyhow::anyhow!("Output path is required"));
|
||||
}
|
||||
if !out_path.to_ascii_lowercase().ends_with(".zip") {
|
||||
return Err(anyhow::anyhow!("Output file must end with .zip"));
|
||||
}
|
||||
|
||||
let out = resolve_tilde(app, out_path)?;
|
||||
if let Some(parent) = out.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent).map_err(anyhow::Error::from)?;
|
||||
}
|
||||
}
|
||||
|
||||
let file = fs::File::create(&out).map_err(anyhow::Error::from)?;
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let data_dir = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))?
|
||||
.join(".netmount");
|
||||
|
||||
// 递归添加目录内容(排除 log 目录)
|
||||
fn add_dir_to_zip(
|
||||
zip: &mut zip::ZipWriter<fs::File>,
|
||||
options: &zip::write::FileOptions<'_, ()>,
|
||||
src_dir: &Path,
|
||||
base_dir: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
for entry in fs::read_dir(src_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name.to_string_lossy().to_lowercase();
|
||||
|
||||
// 排除 log 目录
|
||||
if path.is_dir() && file_name_str == "log" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let relative_path = path.strip_prefix(base_dir)?;
|
||||
|
||||
if path.is_dir() {
|
||||
add_dir_to_zip(zip, options, &path, base_dir)?;
|
||||
} else {
|
||||
let entry_name = relative_path.to_string_lossy().replace('\\', "/");
|
||||
zip.start_file(&entry_name, *options)?;
|
||||
let mut file = fs::File::open(&path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
zip.write_all(&buffer)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if data_dir.exists() {
|
||||
add_dir_to_zip(&mut zip, &options, &data_dir, &data_dir)?;
|
||||
}
|
||||
|
||||
zip.finish().map_err(anyhow::Error::from)?;
|
||||
Ok(out.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
inner(&app, &out_path).map_err(Into::into)
|
||||
}
|
||||
|
||||
/// 从 zip 文件导入配置
|
||||
#[tauri::command]
|
||||
pub fn import_config(
|
||||
app: tauri::AppHandle<Runtime>,
|
||||
zip_path: String,
|
||||
) -> anyhow_tauri::TAResult<String> {
|
||||
fn inner(app: &tauri::AppHandle<Runtime>, zip_path: &str) -> anyhow::Result<String> {
|
||||
let zip_path = resolve_tilde(app, zip_path)?;
|
||||
|
||||
if !zip_path.exists() {
|
||||
return Err(anyhow::anyhow!("Backup file does not exist"));
|
||||
}
|
||||
|
||||
if !zip_path.to_string_lossy().to_ascii_lowercase().ends_with(".zip") {
|
||||
return Err(anyhow::anyhow!("Backup file must be a zip file"));
|
||||
}
|
||||
|
||||
// 打开 zip 文件
|
||||
let file = fs::File::open(&zip_path)?;
|
||||
let mut zip = zip::ZipArchive::new(file)?;
|
||||
|
||||
// 验证目录结构
|
||||
validate_zip_structure(&mut zip)?;
|
||||
|
||||
// 解压到临时目录
|
||||
let data_dir = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))?
|
||||
.join(".netmount");
|
||||
let temp_dir = data_dir.join(".backup_temp");
|
||||
|
||||
// 如果临时目录已存在,先删除
|
||||
if temp_dir.exists() {
|
||||
fs::remove_dir_all(&temp_dir)?;
|
||||
}
|
||||
fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
// 解压所有文件到临时目录
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i)?;
|
||||
let entry_name = entry.name().to_string();
|
||||
|
||||
// 跳过目录条目(zip 中目录条目通常以/结尾)
|
||||
if entry_name.ends_with('/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_path = temp_dir.join(&entry_name);
|
||||
|
||||
// 确保父目录存在
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// 如果是目录,创建它
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&target_path)?;
|
||||
} else {
|
||||
// 如果是文件,解压它
|
||||
let mut outfile = fs::File::create(&target_path)?;
|
||||
std::io::copy(&mut entry, &mut outfile)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 备份当前配置(以防万一)
|
||||
let backup_dir = data_dir.join(".backup_old");
|
||||
if data_dir.exists() {
|
||||
if backup_dir.exists() {
|
||||
fs::remove_dir_all(&backup_dir)?;
|
||||
}
|
||||
// 复制当前配置到备份目录(排除临时目录)
|
||||
for entry in fs::read_dir(&data_dir)? {
|
||||
let entry = entry?;
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name.to_string_lossy();
|
||||
|
||||
// 跳过临时目录和备份目录
|
||||
if file_name_str == ".backup_temp" || file_name_str == ".backup_old" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src = entry.path();
|
||||
let dst = backup_dir.join(&file_name);
|
||||
|
||||
if src.is_dir() {
|
||||
copy_dir_all(&src, &dst)?;
|
||||
} else {
|
||||
fs::copy(&src, &dst)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除旧配置
|
||||
for entry in fs::read_dir(&data_dir)? {
|
||||
let entry = entry?;
|
||||
let file_name = entry.file_name();
|
||||
let file_name_str = file_name.to_string_lossy();
|
||||
|
||||
// 跳过临时目录和备份目录
|
||||
if file_name_str == ".backup_temp" || file_name_str == ".backup_old" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(&path)?;
|
||||
} else {
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 将新配置从临时目录移动到数据目录
|
||||
for entry in fs::read_dir(&temp_dir)? {
|
||||
let entry = entry?;
|
||||
let src = entry.path();
|
||||
let dst = data_dir.join(entry.file_name());
|
||||
|
||||
if src.is_dir() {
|
||||
copy_dir_all(&src, &dst)?;
|
||||
} else {
|
||||
fs::copy(&src, &dst)?;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理临时目录
|
||||
fs::remove_dir_all(&temp_dir)?;
|
||||
|
||||
// 保留旧备份(可选:可以在成功后删除)
|
||||
// fs::remove_dir_all(&backup_dir)?;
|
||||
|
||||
Ok(format!("Configuration imported successfully from {}", zip_path.display()))
|
||||
}
|
||||
|
||||
inner(&app, &zip_path).map_err(Into::into)
|
||||
}
|
||||
|
||||
@@ -219,7 +219,9 @@ pub fn init() -> anyhow::Result<()> {
|
||||
register_sidecar_pid,
|
||||
spawn_sidecar,
|
||||
run_sidecar_once,
|
||||
kill_sidecar
|
||||
kill_sidecar,
|
||||
fs::export_config,
|
||||
fs::import_config
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化 Job Object(Windows 进程树管理)
|
||||
|
||||
@@ -222,7 +222,7 @@ async function updateOpenlistStorageInfoList() {
|
||||
|
||||
// 设置不缓存
|
||||
if (option.name === 'cache_expiration') {
|
||||
storageParam.default = "0"; // 不缓存
|
||||
storageParam.default = 0; // 不缓存 (数字类型)
|
||||
}
|
||||
|
||||
// 设置容量使用显示
|
||||
|
||||
@@ -154,6 +154,107 @@ export default function Setting_page() {
|
||||
</Form>
|
||||
|
||||
|
||||
</Card>
|
||||
<Card title={t('data_management')} style={{}} size='small'>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.5rem' }}>{t('export_config')}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#86909c' }}>
|
||||
{t('export_config_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" status="success" onClick={async () => {
|
||||
try {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const path = await dialog.save({
|
||||
title: t('export_config'),
|
||||
defaultPath: `netmount-config-${ts}.zip`,
|
||||
filters: [{ name: 'Zip', extensions: ['zip'] }],
|
||||
})
|
||||
if (!path) return
|
||||
const out = await invoke<string>('export_config', { outPath: path })
|
||||
Message.success(`${t('config_exported')}: ${out}`)
|
||||
} catch (e) {
|
||||
const msg = (() => {
|
||||
if (typeof e === 'string') return e
|
||||
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message?: unknown }).message === 'string') {
|
||||
return (e as { message: string }).message
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(e)
|
||||
} catch {
|
||||
return String(e)
|
||||
}
|
||||
})()
|
||||
Message.error(msg)
|
||||
}
|
||||
}}>{t('export')}</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ height: '1px', backgroundColor: '#e5e6eb', margin: '0.5rem 0' }} />
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '0.5rem' }}>{t('import_config')}</div>
|
||||
<div style={{ fontSize: '0.85rem', color: '#86909c' }}>
|
||||
{t('import_config_description')}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" status="warning" onClick={async () => {
|
||||
try {
|
||||
const path = await dialog.open({
|
||||
title: t('import_config'),
|
||||
multiple: false,
|
||||
filters: [{ name: 'Zip', extensions: ['zip'] }],
|
||||
})
|
||||
if (!path) return
|
||||
|
||||
Modal.confirm({
|
||||
title: t('confirm_import'),
|
||||
content: t('confirm_import_description'),
|
||||
okButtonProps: { status: 'warning' },
|
||||
onOk: async () => {
|
||||
try {
|
||||
const result = await invoke<string>('import_config', { zipPath: path })
|
||||
Message.success(result)
|
||||
// 延迟重启
|
||||
setTimeout(() => {
|
||||
exit(true)
|
||||
}, 1000)
|
||||
} catch (e) {
|
||||
const msg = (() => {
|
||||
if (typeof e === 'string') return e
|
||||
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message?: unknown }).message === 'string') {
|
||||
return (e as { message: string }).message
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(e)
|
||||
} catch {
|
||||
return String(e)
|
||||
}
|
||||
})()
|
||||
Message.error(msg)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = (() => {
|
||||
if (typeof e === 'string') return e
|
||||
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message?: unknown }).message === 'string') {
|
||||
return (e as { message: string }).message
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(e)
|
||||
} catch {
|
||||
return String(e)
|
||||
}
|
||||
})()
|
||||
Message.error(msg)
|
||||
}
|
||||
}}>{t('import')}</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card title={t('components')} style={{}} size='small'>
|
||||
<Link onClick={() => { shell.open(roConfig.url.rclone) }}>Rclone</Link>(<Link onClick={() => {
|
||||
|
||||
@@ -43,6 +43,7 @@ function AddStorage_page() {
|
||||
const isEditMode = (getURLSearchParam('edit') == 'true')
|
||||
const [formHook, setFormHook] = useState<FormInstance>();//表单实例
|
||||
const [searchStr, setSearchStr] = useState('')//搜索存储
|
||||
const [submitting, setSubmitting] = useState(false)//提交中状态
|
||||
|
||||
|
||||
const [storageParams, setStorageParams] = useState<ParametersType>()//编辑模式下,覆盖默认参数
|
||||
@@ -175,9 +176,9 @@ function AddStorage_page() {
|
||||
header={<FormItem label={'*' + t('storage_name')} hidden={isEditMode}>
|
||||
<Input value={storageName || ''} onChange={(value) => {
|
||||
setStorageName(value)
|
||||
}} />
|
||||
}} disabled={submitting} />
|
||||
</FormItem>} data={[...storageInfo.defaultParams.parameters/* ,...storageInfo.defaultParams.exParameters?.openlist?.additional||[] */]}
|
||||
showAdvanced={showAdvanced} overwriteValues={storageParams || {}} setFormHook={(hook) => { setFormHook(hook) }} />
|
||||
showAdvanced={showAdvanced} overwriteValues={storageParams || {}} setFormHook={(hook) => { setFormHook(hook) }} disabled={submitting} />
|
||||
<br />
|
||||
|
||||
<Row style={{ width: '100%' }}>
|
||||
@@ -199,6 +200,12 @@ function AddStorage_page() {
|
||||
<Button onClick={() => { getURLSearchParam('edit') ? navigate('/storage/manage') : setStep('selectType') }}>{t('step_back')}</Button>
|
||||
<Button onClick={async () => {
|
||||
if (!formHook) return;
|
||||
|
||||
// 防止重复提交
|
||||
if (submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
await formHook.validate()
|
||||
} catch (error) {
|
||||
@@ -209,6 +216,7 @@ function AddStorage_page() {
|
||||
Message.error(t(err.key) + t(errorValue.message.replace(err.key, '')))
|
||||
}
|
||||
})
|
||||
setSubmitting(false);
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,12 +224,14 @@ function AddStorage_page() {
|
||||
if (!isEditMode) {
|
||||
if (searchStorage(storageName)?.name) {
|
||||
Message.error(t('storage_name_already_exists'))
|
||||
setSubmitting(false);
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!storageName) {
|
||||
Message.error(t('storage_name_cannot_be_empty'))
|
||||
setSubmitting(false);
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,14 +246,16 @@ function AddStorage_page() {
|
||||
content: t('Storage_added_successfully'),
|
||||
})
|
||||
navigate('/storage/manage')
|
||||
// 成功后不需要恢复状态,因为会跳转
|
||||
} else {
|
||||
Notification.error({
|
||||
title: t('error'),
|
||||
content: t('Storage_added_failed'),
|
||||
})
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
} type='primary'>{t('save')}</Button>
|
||||
} type='primary' loading={submitting} disabled={submitting}>{submitting ? t('saving') : t('save')}</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
Reference in New Issue
Block a user