✨ 添加下载管理和 metalink 解析
This commit is contained in:
25
src-tauri/Cargo.lock
generated
25
src-tauri/Cargo.lock
generated
@@ -2943,7 +2943,7 @@ checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.7.1",
|
||||
"quick-xml",
|
||||
"quick-xml 0.32.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3064,6 +3064,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.31.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.32.0"
|
||||
@@ -3620,6 +3629,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.8"
|
||||
@@ -3745,15 +3765,18 @@ dependencies = [
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"pinyin",
|
||||
"quick-xml 0.31.0",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
"tokio-macros",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -23,10 +23,13 @@ tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12.12", features = ["json"] }
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
tokio = { version = "1.43.0", features = ["full", "sync"] }
|
||||
tokio-macros = { version = "2.5.0" }
|
||||
tokio-util = { version = "0.7.10", features = ["io"] }
|
||||
lazy_static = "1.5.0"
|
||||
futures = "0.3.31"
|
||||
pinyin = "0.10.0"
|
||||
tauri-plugin-clipboard-manager = "2.2.0"
|
||||
dirs = "6.0.0"
|
||||
quick-xml = "0.31.0"
|
||||
sha1 = "0.10.6"
|
||||
|
||||
@@ -1,153 +1,28 @@
|
||||
use crate::models::download::{DownloadStatus, DownloadTask};
|
||||
use tauri::State;
|
||||
use std::sync::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use crate::handlers::server::get_json_server_url;
|
||||
use crate::utils::{format_icon_url, UA};
|
||||
|
||||
pub type DownloadQueue = Mutex<HashMap<String, DownloadTask>>;
|
||||
use crate::{models::download::DownloadTask, utils::download_manager::DownloadManager};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_downloads(queue: State<'_, DownloadQueue>) -> Result<Vec<DownloadTask>, String> {
|
||||
let downloads = queue.lock().map_err(|e| e.to_string())?;
|
||||
Ok(downloads.values().cloned().collect())
|
||||
}
|
||||
|
||||
// 检查是否有正在下载的任务
|
||||
fn has_downloading_task(downloads: &HashMap<String, DownloadTask>) -> bool {
|
||||
downloads.values().any(|task| matches!(task.status, DownloadStatus::Downloading))
|
||||
}
|
||||
|
||||
// 获取下一个等待下载的任务
|
||||
fn get_next_queued_task(downloads: &mut HashMap<String, DownloadTask>) -> Option<String> {
|
||||
downloads
|
||||
.iter()
|
||||
.find(|(_, task)| matches!(task.status, DownloadStatus::Queued))
|
||||
.map(|(task_id, _)| task_id.clone())
|
||||
}
|
||||
|
||||
// 开始下载下一个任务
|
||||
fn start_next_download(downloads: &mut HashMap<String, DownloadTask>) {
|
||||
if let Some(task_id) = get_next_queued_task(downloads) {
|
||||
if let Some(task) = downloads.get_mut(&task_id) {
|
||||
task.status = DownloadStatus::Downloading;
|
||||
}
|
||||
}
|
||||
pub async fn get_downloads(manager: State<'_, DownloadManager>) -> Result<Vec<DownloadTask>, String> {
|
||||
manager.get_downloads()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_download(category: String, pkgname: String, filename:String, name:String, queue: State<'_, DownloadQueue>) -> Result<(), String> {
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
// 检查任务是否已存在
|
||||
{
|
||||
let downloads = queue.lock().map_err(|e| e.to_string())?;
|
||||
if downloads.contains_key(&task_id) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 获取metalink文件URL
|
||||
let json_server_url = get_json_server_url();
|
||||
let metalink_url = format!("{}{}/{}/{}.metalink", json_server_url, category, pkgname, filename);
|
||||
// 发送请求获取metalink文件
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&metalink_url)
|
||||
.header("User-Agent", UA)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("获取metalink文件失败: {}", e))?;
|
||||
|
||||
// 检查响应状态
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("获取metalink文件失败: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let mut downloads = queue.lock().map_err(|e| e.to_string())?;
|
||||
let initial_status = if has_downloading_task(&downloads) {
|
||||
DownloadStatus::Queued
|
||||
} else {
|
||||
DownloadStatus::Downloading
|
||||
};
|
||||
|
||||
// 创建下载任务
|
||||
let task = DownloadTask {
|
||||
category: category.clone(),
|
||||
pkgname: pkgname.clone(),
|
||||
filename,
|
||||
name,
|
||||
icon: format_icon_url(&category, &pkgname),
|
||||
status: initial_status,
|
||||
progress: 0.0,
|
||||
speed: None,
|
||||
size: None,
|
||||
};
|
||||
|
||||
// 添加任务到队列
|
||||
downloads.insert(task_id, task);
|
||||
|
||||
Ok(())
|
||||
pub async fn add_download(category: String, pkgname: String, filename: String, name: String, manager: State<'_, DownloadManager>) -> Result<(), String> {
|
||||
manager.add_download(category, pkgname, filename, name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pause_download(category: String, pkgname: String, queue: State<'_, DownloadQueue>) -> Result<(), String> {
|
||||
let mut downloads = queue.lock().map_err(|e| e.to_string())?;
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
if let Some(task) = downloads.get_mut(&task_id) {
|
||||
let old_status = task.status.clone();
|
||||
task.status = DownloadStatus::Paused;
|
||||
if matches!(old_status, DownloadStatus::Downloading) {
|
||||
start_next_download(&mut downloads);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
pub async fn pause_download(category: String, pkgname: String, manager: State<'_, DownloadManager>) -> Result<(), String> {
|
||||
manager.pause_download(category, pkgname)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resume_download(category: String, pkgname: String, queue: State<'_, DownloadQueue>) -> Result<(), String> {
|
||||
let mut downloads = queue.lock().map_err(|e| e.to_string())?;
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
// 先检查任务是否存在且处于暂停状态
|
||||
let should_resume = downloads
|
||||
.get(&task_id)
|
||||
.map(|task| matches!(task.status, DownloadStatus::Paused))
|
||||
.unwrap_or(false);
|
||||
|
||||
if should_resume {
|
||||
// 检查是否有其他正在下载的任务
|
||||
let has_downloading = has_downloading_task(&downloads);
|
||||
|
||||
// 更新任务状态
|
||||
if let Some(task) = downloads.get_mut(&task_id) {
|
||||
task.status = if has_downloading {
|
||||
DownloadStatus::Queued
|
||||
} else {
|
||||
DownloadStatus::Downloading
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
pub async fn resume_download(category: String, pkgname: String, manager: State<'_, DownloadManager>) -> Result<(), String> {
|
||||
manager.resume_download(category, pkgname)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_download(category: String, pkgname: String, queue: State<'_, DownloadQueue>) -> Result<(), String> {
|
||||
let mut downloads = queue.lock().map_err(|e| e.to_string())?;
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
let was_downloading = downloads
|
||||
.get(&task_id)
|
||||
.map(|task| matches!(task.status, DownloadStatus::Downloading))
|
||||
.unwrap_or(false);
|
||||
|
||||
downloads.remove(&task_id);
|
||||
|
||||
if was_downloading {
|
||||
start_next_download(&mut downloads);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
pub async fn cancel_download(category: String, pkgname: String, manager: State<'_, DownloadManager>) -> Result<(), String> {
|
||||
manager.cancel_download(category, pkgname)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.manage(handlers::download::DownloadQueue::default())
|
||||
.manage(utils::download_manager::DownloadManager::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
handlers::category::get_all_categories,
|
||||
handlers::category::get_category_apps,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::utils::metalink::MetalinkFile;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadTask {
|
||||
pub category: String,
|
||||
@@ -11,6 +13,7 @@ pub struct DownloadTask {
|
||||
pub name: String,
|
||||
pub speed: Option<String>,
|
||||
pub size: Option<String>,
|
||||
pub metalink: MetalinkFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
||||
178
src-tauri/src/utils/download_manager.rs
Normal file
178
src-tauri/src/utils/download_manager.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use crate::models::download::{DownloadStatus, DownloadTask};
|
||||
use crate::utils::metalink::parse_metalink;
|
||||
use crate::handlers::server::get_json_server_url;
|
||||
use crate::utils::{format_icon_url, UA};
|
||||
|
||||
pub struct DownloadManager {
|
||||
queue: Mutex<HashMap<String, DownloadTask>>,
|
||||
}
|
||||
|
||||
impl DownloadManager {
|
||||
pub fn new() -> Self {
|
||||
DownloadManager {
|
||||
queue: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有下载任务
|
||||
pub fn get_downloads(&self) -> Result<Vec<DownloadTask>, String> {
|
||||
let downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
Ok(downloads.values().cloned().collect())
|
||||
}
|
||||
|
||||
// 检查是否有正在下载的任务
|
||||
fn has_downloading_task(&self) -> Result<bool, String> {
|
||||
let downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
Ok(downloads.values().any(|task| matches!(task.status, DownloadStatus::Downloading)))
|
||||
}
|
||||
|
||||
// 获取下一个等待下载的任务
|
||||
fn get_next_queued_task(&self) -> Result<Option<String>, String> {
|
||||
let downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
Ok(downloads
|
||||
.iter()
|
||||
.find(|(_, task)| matches!(task.status, DownloadStatus::Queued))
|
||||
.map(|(task_id, _)| task_id.clone()))
|
||||
}
|
||||
|
||||
// 开始下载下一个任务
|
||||
fn start_next_download(&self) -> Result<(), String> {
|
||||
let mut downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(task_id) = self.get_next_queued_task()? {
|
||||
if let Some(task) = downloads.get_mut(&task_id) {
|
||||
task.status = DownloadStatus::Downloading;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 添加下载任务
|
||||
pub async fn add_download(&self, category: String, pkgname: String, filename: String, name: String) -> Result<(), String> {
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
// 获取metalink文件URL和内容(在获取锁之前完成)
|
||||
let json_server_url = get_json_server_url();
|
||||
let metalink_url = format!("{}{}/{}/{}.metalink", json_server_url, category, pkgname, filename);
|
||||
|
||||
// 发送请求获取metalink文件
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(&metalink_url)
|
||||
.header("User-Agent", UA)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("获取metalink文件失败: {}", e))?;
|
||||
|
||||
// 检查响应状态
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("获取metalink文件失败: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
// 解析 metalink 内容
|
||||
let metalink_content = response.text().await
|
||||
.map_err(|e| format!("读取metalink内容失败: {}", e))?;
|
||||
|
||||
let metalink_data = parse_metalink(&metalink_content)?;
|
||||
|
||||
println!("{:?}", metalink_data);
|
||||
|
||||
// 获取一次锁,完成所有状态更新操作
|
||||
let mut downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// 检查任务是否已存在
|
||||
if downloads.contains_key(&task_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 检查是否有正在下载的任务
|
||||
let has_downloading = downloads.values().any(|task| matches!(task.status, DownloadStatus::Downloading));
|
||||
let initial_status = if has_downloading {
|
||||
DownloadStatus::Queued
|
||||
} else {
|
||||
DownloadStatus::Downloading
|
||||
};
|
||||
|
||||
// 创建下载任务
|
||||
let task = DownloadTask {
|
||||
category: category.clone(),
|
||||
pkgname: pkgname.clone(),
|
||||
filename,
|
||||
name,
|
||||
icon: format_icon_url(&category, &pkgname),
|
||||
status: initial_status,
|
||||
progress: 0.0,
|
||||
speed: None,
|
||||
size: None,
|
||||
metalink: metalink_data,
|
||||
};
|
||||
|
||||
// 添加任务到队列
|
||||
downloads.insert(task_id, task);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 暂停下载任务
|
||||
pub fn pause_download(&self, category: String, pkgname: String) -> Result<(), String> {
|
||||
let mut downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
if let Some(task) = downloads.get_mut(&task_id) {
|
||||
let old_status = task.status.clone();
|
||||
task.status = DownloadStatus::Paused;
|
||||
if matches!(old_status, DownloadStatus::Downloading) {
|
||||
self.start_next_download()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 恢复下载任务
|
||||
pub fn resume_download(&self, category: String, pkgname: String) -> Result<(), String> {
|
||||
let mut downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
// 先检查任务是否存在且处于暂停状态
|
||||
let should_resume = downloads
|
||||
.get(&task_id)
|
||||
.map(|task| matches!(task.status, DownloadStatus::Paused))
|
||||
.unwrap_or(false);
|
||||
|
||||
if should_resume {
|
||||
// 检查是否有其他正在下载的任务
|
||||
let has_downloading = self.has_downloading_task()?;
|
||||
|
||||
// 更新任务状态
|
||||
if let Some(task) = downloads.get_mut(&task_id) {
|
||||
task.status = if has_downloading {
|
||||
DownloadStatus::Queued
|
||||
} else {
|
||||
DownloadStatus::Downloading
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 取消下载任务
|
||||
pub fn cancel_download(&self, category: String, pkgname: String) -> Result<(), String> {
|
||||
let mut downloads = self.queue.lock().map_err(|e| e.to_string())?;
|
||||
let task_id = format!("{}/{}", category, pkgname);
|
||||
|
||||
let was_downloading = downloads
|
||||
.get(&task_id)
|
||||
.map(|task| matches!(task.status, DownloadStatus::Downloading))
|
||||
.unwrap_or(false);
|
||||
|
||||
downloads.remove(&task_id);
|
||||
|
||||
if was_downloading {
|
||||
self.start_next_download()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
116
src-tauri/src/utils/metalink.rs
Normal file
116
src-tauri/src/utils/metalink.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MetalinkFile {
|
||||
pub name: String,
|
||||
pub hashes: HashMap<String, String>,
|
||||
pub urls: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn parse_metalink(content: &str) -> Result<MetalinkFile, String> {
|
||||
let mut reader = Reader::from_str(content);
|
||||
reader.trim_text(true);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut file = MetalinkFile {
|
||||
name: String::new(),
|
||||
hashes: HashMap::new(),
|
||||
urls: Vec::new(),
|
||||
};
|
||||
|
||||
let mut in_file = false;
|
||||
let mut in_verification = false;
|
||||
let mut current_hash_type = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
match e.name().as_ref() {
|
||||
b"file" => {
|
||||
in_file = true;
|
||||
if let Ok(attr) = e.attributes().next().unwrap() {
|
||||
if attr.key.as_ref() == b"name" {
|
||||
file.name = String::from_utf8_lossy(&attr.value).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
b"verification" => in_verification = true,
|
||||
b"hash" => {
|
||||
if in_verification {
|
||||
if let Ok(attr) = e.attributes().next().unwrap() {
|
||||
if attr.key.as_ref() == b"type" {
|
||||
current_hash_type = String::from_utf8_lossy(&attr.value).to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"url" => {
|
||||
if in_file {
|
||||
// 暂时不处理 url 的属性,直接获取内容
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if !current_hash_type.is_empty() {
|
||||
file.hashes.insert(current_hash_type.clone(), e.unescape().unwrap().to_string());
|
||||
current_hash_type.clear();
|
||||
} else if in_file {
|
||||
let text = e.unescape().unwrap();
|
||||
if text.trim().starts_with("http") {
|
||||
file.urls.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(ref e)) => {
|
||||
match e.name().as_ref() {
|
||||
b"file" => in_file = false,
|
||||
b"verification" => in_verification = false,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(format!("解析错误: {}", e)),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
if file.name.is_empty() {
|
||||
return Err("未找到文件名".to_string());
|
||||
}
|
||||
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_metalink() {
|
||||
let content = r#"<?xml version="1.0" ?>
|
||||
<metalink xmlns="http://www.metalinker.org/" version="3.0">
|
||||
<files>
|
||||
<file name="wechat_4.0.1.11_amd64.deb">
|
||||
<verification>
|
||||
<hash type="md5">a6af060b8a27417ed972b8b004815725</hash>
|
||||
<hash type="sha1">45a8758392ee4feae9da4f53374721138ab66dac</hash>
|
||||
</verification>
|
||||
<resources>
|
||||
<url type="http" location="cn" preference="100">https://mirrors.sdu.edu.cn/spark-store/store/./chat/wechat/wechat_4.0.1.11_amd64.deb</url>
|
||||
<url type="http" location="cn" preference="100">https://spark.home.sunnypai.top:43443/store/./chat/wechat/wechat_4.0.1.11_amd64.deb</url>
|
||||
</resources>
|
||||
</file>
|
||||
</files>
|
||||
</metalink>"#;
|
||||
|
||||
let result = parse_metalink(content).unwrap();
|
||||
assert_eq!(result.name, "wechat_4.0.1.11_amd64.deb");
|
||||
assert_eq!(result.hashes.get("md5").unwrap(), "a6af060b8a27417ed972b8b004815725");
|
||||
assert_eq!(result.urls.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::handlers::server::get_img_server_url;
|
||||
|
||||
pub mod search;
|
||||
pub mod metalink;
|
||||
pub mod download_manager;
|
||||
|
||||
pub const UA: &str = concat!("Spark-Store/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user