Compare commits
2 Commits
3f23d6b549
...
07a8c6a11d
Author | SHA1 | Date | |
---|---|---|---|
07a8c6a11d | |||
f7d92243c9 |
@ -6,10 +6,10 @@ compile_error!("Crate wfassoc is only supported on Windows.");
|
|||||||
|
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
use thiserror::Error as TeError;
|
use thiserror::Error as TeError;
|
||||||
|
use winreg::RegKey;
|
||||||
|
|
||||||
// region: Error Types
|
// region: Error Types
|
||||||
|
|
||||||
@ -21,35 +21,16 @@ pub enum Error {
|
|||||||
)]
|
)]
|
||||||
NoPrivilege,
|
NoPrivilege,
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
Register(#[from] std::io::Error),
|
||||||
|
#[error("{0}")]
|
||||||
BadFileExt(#[from] ParseFileExtError),
|
BadFileExt(#[from] ParseFileExtError),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
BadExecRc(#[from] ParseExecRcError),
|
BadProgId(#[from] ParseProgIdError),
|
||||||
}
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region: Basic Types
|
// region: Privilege, Scope and View
|
||||||
|
|
||||||
/// The scope where wfassoc will register and unregister.
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
|
||||||
pub enum Scope {
|
|
||||||
/// Scope for current user.
|
|
||||||
User,
|
|
||||||
/// Scope for all users under this computer.
|
|
||||||
System,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The view when wfassoc querying infomations.
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
|
||||||
pub enum View {
|
|
||||||
/// The view of current user.
|
|
||||||
User,
|
|
||||||
/// The view of system.
|
|
||||||
System,
|
|
||||||
/// Hybrid view of User and System.
|
|
||||||
/// It can be seen as that we use System first and then use User to override any existing items.
|
|
||||||
Hybrid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether current process has administrative privilege.
|
/// Check whether current process has administrative privilege.
|
||||||
///
|
///
|
||||||
@ -104,6 +85,71 @@ pub fn has_privilege() -> bool {
|
|||||||
is_member != 0
|
is_member != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The scope where wfassoc will register and unregister.
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub enum Scope {
|
||||||
|
/// Scope for current user.
|
||||||
|
User,
|
||||||
|
/// Scope for all users under this computer.
|
||||||
|
System,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view when wfassoc querying infomations.
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub enum View {
|
||||||
|
/// The view of current user.
|
||||||
|
User,
|
||||||
|
/// The view of system.
|
||||||
|
System,
|
||||||
|
/// Hybrid view of User and System.
|
||||||
|
/// It can be seen as that we use System first and then use User to override any existing items.
|
||||||
|
Hybrid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The error occurs when cast View into Scope.
|
||||||
|
#[derive(Debug, TeError)]
|
||||||
|
#[error("hybrid view can not be cast into any scope")]
|
||||||
|
pub struct TryFromViewError {}
|
||||||
|
|
||||||
|
impl TryFromViewError {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Scope> for View {
|
||||||
|
fn from(value: Scope) -> Self {
|
||||||
|
match value {
|
||||||
|
Scope::User => Self::User,
|
||||||
|
Scope::System => Self::System,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<View> for Scope {
|
||||||
|
type Error = TryFromViewError;
|
||||||
|
|
||||||
|
fn try_from(value: View) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
View::User => Ok(Self::User),
|
||||||
|
View::System => Ok(Self::System),
|
||||||
|
View::Hybrid => Err(TryFromViewError::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scope {
|
||||||
|
/// Check whether we have enough privilege when operating in current scope.
|
||||||
|
/// If we have, simply return, otherwise return error.
|
||||||
|
fn check_privilege(&self) -> Result<(), Error> {
|
||||||
|
if matches!(self, Self::System if !has_privilege()) {
|
||||||
|
Err(Error::NoPrivilege)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// region: File Extension
|
// region: File Extension
|
||||||
@ -118,23 +164,13 @@ pub struct FileExt {
|
|||||||
|
|
||||||
impl FileExt {
|
impl FileExt {
|
||||||
pub fn new(file_ext: &str) -> Result<Self, ParseFileExtError> {
|
pub fn new(file_ext: &str) -> Result<Self, ParseFileExtError> {
|
||||||
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\.([^\.]+)$").unwrap());
|
Self::from_str(file_ext)
|
||||||
match RE.captures(file_ext) {
|
|
||||||
Some(v) => Ok(Self {
|
|
||||||
inner: v[1].to_string(),
|
|
||||||
}),
|
|
||||||
None => Err(ParseFileExtError::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn query(&self, view: View) -> Option<FileExtAssoc> {
|
|
||||||
FileExtAssoc::new(self, view)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The error occurs when try parsing string into FileExt.
|
/// The error occurs when try parsing string into FileExt.
|
||||||
#[derive(Debug, TeError)]
|
#[derive(Debug, TeError)]
|
||||||
#[error("given file extension is illegal")]
|
#[error("given file extension is invalid")]
|
||||||
pub struct ParseFileExtError {}
|
pub struct ParseFileExtError {}
|
||||||
|
|
||||||
impl ParseFileExtError {
|
impl ParseFileExtError {
|
||||||
@ -153,7 +189,81 @@ impl FromStr for FileExt {
|
|||||||
type Err = ParseFileExtError;
|
type Err = ParseFileExtError;
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
Self::new(s)
|
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\.([^\.]+)$").unwrap());
|
||||||
|
match RE.captures(s) {
|
||||||
|
Some(v) => Ok(Self {
|
||||||
|
inner: v[1].to_string(),
|
||||||
|
}),
|
||||||
|
None => Err(ParseFileExtError::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileExt {
|
||||||
|
fn open_scope(&self, scope: Scope) -> Result<RegKey, Error> {
|
||||||
|
use winreg::enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WRITE};
|
||||||
|
|
||||||
|
// check privilege
|
||||||
|
scope.check_privilege()?;
|
||||||
|
// get the root key
|
||||||
|
let hk = match scope {
|
||||||
|
Scope::User => RegKey::predef(HKEY_CURRENT_USER),
|
||||||
|
Scope::System => RegKey::predef(HKEY_LOCAL_MACHINE),
|
||||||
|
};
|
||||||
|
// navigate to classes
|
||||||
|
let classes = hk.open_subkey_with_flags("Software\\Classes", KEY_READ | KEY_WRITE)?;
|
||||||
|
// okey
|
||||||
|
Ok(classes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_view(&self, view: View) -> Result<Option<RegKey>, Error> {
|
||||||
|
use winreg::enums::{HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ};
|
||||||
|
|
||||||
|
// navigate to extension container
|
||||||
|
let hk = match view {
|
||||||
|
View::User => RegKey::predef(HKEY_CURRENT_USER),
|
||||||
|
View::System => RegKey::predef(HKEY_LOCAL_MACHINE),
|
||||||
|
View::Hybrid => RegKey::predef(HKEY_CLASSES_ROOT),
|
||||||
|
};
|
||||||
|
let classes = match view {
|
||||||
|
View::User | View::System => {
|
||||||
|
hk.open_subkey_with_flags("Software\\Classes", KEY_READ)?
|
||||||
|
}
|
||||||
|
View::Hybrid => hk.open_subkey_with_flags("", KEY_READ)?,
|
||||||
|
};
|
||||||
|
// check whether there is this ext
|
||||||
|
classes.
|
||||||
|
// open extension key if possible
|
||||||
|
let thisext = classes.open_subkey_with_flags(file_ext.to_string(), KEY_READ)?;
|
||||||
|
// okey
|
||||||
|
Ok(classes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current(&self, view: View) -> Option<ProgId> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_current(&mut self, scope: Scope, prog_id: Option<&ProgId>) -> Result<(), Error> {
|
||||||
|
scope.check_privilege()?;
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter_open_with(&self, view: View) -> Result<impl Iterator<Item = ProgId>, Error> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_open_with(&mut self, scope: Scope, prog_id: &ProgId) -> Result<(), Error> {
|
||||||
|
scope.check_privilege()?;
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn flash_open_with(
|
||||||
|
&mut self,
|
||||||
|
scope: Scope,
|
||||||
|
prog_ids: impl Iterator<Item = ProgId>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
scope.check_privilege()?;
|
||||||
|
todo!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,7 +302,11 @@ impl FileExtAssoc {
|
|||||||
let default = thisext.get_value("").unwrap_or(String::new());
|
let default = thisext.get_value("").unwrap_or(String::new());
|
||||||
let open_with_progids =
|
let open_with_progids =
|
||||||
if let Ok(progids) = thisext.open_subkey_with_flags("OpenWithProdIds", KEY_READ) {
|
if let Ok(progids) = thisext.open_subkey_with_flags("OpenWithProdIds", KEY_READ) {
|
||||||
progids.enum_keys().map(|x| x.unwrap()).filter(|k| !k.is_empty()).collect()
|
progids
|
||||||
|
.enum_keys()
|
||||||
|
.map(|x| x.unwrap())
|
||||||
|
.filter(|k| !k.is_empty())
|
||||||
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
@ -220,71 +334,211 @@ impl FileExtAssoc {
|
|||||||
|
|
||||||
// region: Executable Resource
|
// region: Executable Resource
|
||||||
|
|
||||||
/// The struct representing an Windows executable resources path like
|
// /// The struct representing an Windows executable resources path like
|
||||||
/// `path_to_file.exe,1`.
|
// /// `path_to_file.exe,1`.
|
||||||
pub struct ExecRc {
|
// pub struct ExecRc {
|
||||||
/// The path to binary for finding resources.
|
// /// The path to binary for finding resources.
|
||||||
binary: PathBuf,
|
// binary: PathBuf,
|
||||||
/// The inner index of resources.
|
// /// The inner index of resources.
|
||||||
index: u32,
|
// index: u32,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// impl ExecRc {
|
||||||
|
// pub fn new(res_str: &str) -> Result<Self, ParseExecRcError> {
|
||||||
|
// static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^([^,]+),([0-9]+)$").unwrap());
|
||||||
|
// let caps = RE.captures(res_str);
|
||||||
|
// if let Some(caps) = caps {
|
||||||
|
// let binary = PathBuf::from_str(&caps[1])?;
|
||||||
|
// let index = caps[2].parse::<u32>()?;
|
||||||
|
// Ok(Self { binary, index })
|
||||||
|
// } else {
|
||||||
|
// Err(ParseExecRcError::NoCapture)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /// The error occurs when try parsing string into ExecRc.
|
||||||
|
// #[derive(Debug, TeError)]
|
||||||
|
// #[error("given string is not a valid executable resource string")]
|
||||||
|
// pub enum ParseExecRcError {
|
||||||
|
// /// Given string is not matched with format.
|
||||||
|
// NoCapture,
|
||||||
|
// /// Fail to convert executable part into path.
|
||||||
|
// BadBinaryPath(#[from] std::convert::Infallible),
|
||||||
|
// /// Fail to convert index part into valid number.
|
||||||
|
// BadIndex(#[from] std::num::ParseIntError),
|
||||||
|
// }
|
||||||
|
|
||||||
|
// impl FromStr for ExecRc {
|
||||||
|
// type Err = ParseExecRcError;
|
||||||
|
|
||||||
|
// fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
// ExecRc::new(s)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// impl Display for ExecRc {
|
||||||
|
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
// write!(f, "{},{}", self.binary.to_str().unwrap(), self.index)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region: Programmatic Identifiers (ProgId)
|
||||||
|
|
||||||
|
/// The struct representing Programmatic Identifiers (ProgId).
|
||||||
|
///
|
||||||
|
/// Because there is optional part in standard ProgId, and not all software developers
|
||||||
|
/// are willing to following Microsoft suggestions, there is no strict constaint for ProgId.
|
||||||
|
/// So this struct is actually an enum which holding any possible ProgId format.
|
||||||
|
///
|
||||||
|
/// Reference: https://learn.microsoft.com/en-us/windows/win32/shell/fa-progids
|
||||||
|
pub enum ProgId {
|
||||||
|
Plain(String),
|
||||||
|
Loose(LosseProgId),
|
||||||
|
Strict(StrictProgId),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExecRc {
|
impl From<&str> for ProgId {
|
||||||
pub fn new(res_str: &str) -> Result<Self, ParseExecRcError> {
|
fn from(s: &str) -> Self {
|
||||||
static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^([^,]+),([0-9]+)$").unwrap());
|
// match it for strict ProgId first
|
||||||
let caps = RE.captures(res_str);
|
if let Ok(v) = StrictProgId::from_str(s) {
|
||||||
if let Some(caps) = caps {
|
return Self::Strict(v);
|
||||||
let binary = PathBuf::from_str(&caps[1])?;
|
}
|
||||||
let index = caps[2].parse::<u32>()?;
|
// then match for loose ProgId
|
||||||
Ok(Self { binary, index })
|
if let Ok(v) = LosseProgId::from_str(s) {
|
||||||
} else {
|
return Self::Loose(v);
|
||||||
Err(ParseExecRcError::NoCapture)
|
}
|
||||||
|
// fallback with plain
|
||||||
|
Self::Plain(s.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for ProgId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ProgId::Plain(v) => v.fmt(f),
|
||||||
|
ProgId::Loose(v) => v.fmt(f),
|
||||||
|
ProgId::Strict(v) => v.fmt(f),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The error occurs when try parsing string into ExecRc.
|
/// The error occurs when parsing ProgId.
|
||||||
#[derive(Debug, TeError)]
|
#[derive(Debug, TeError)]
|
||||||
#[error("given string is not a valid executable resource string")]
|
#[error("given ProgId string is invalid")]
|
||||||
pub enum ParseExecRcError {
|
pub struct ParseProgIdError {}
|
||||||
/// Given string is not matched with format.
|
|
||||||
NoCapture,
|
|
||||||
/// Fail to convert executable part into path.
|
|
||||||
BadBinaryPath(#[from] std::convert::Infallible),
|
|
||||||
/// Fail to convert index part into valid number.
|
|
||||||
BadIndex(#[from] std::num::ParseIntError),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for ExecRc {
|
impl ParseProgIdError {
|
||||||
type Err = ParseExecRcError;
|
fn new() -> Self {
|
||||||
|
Self {}
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
ExecRc::new(s)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for ExecRc {
|
/// The ProgId similar with strict ProgId, but no version part.
|
||||||
|
pub struct LosseProgId {
|
||||||
|
vendor: String,
|
||||||
|
component: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LosseProgId {
|
||||||
|
pub fn new(vendor: &str, component: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
vendor: vendor.to_string(),
|
||||||
|
component: component.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_vendor(&self) -> &str {
|
||||||
|
&self.vendor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_component(&self) -> &str {
|
||||||
|
&self.component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for LosseProgId {
|
||||||
|
type Err = ParseProgIdError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
static RE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^([a-zA-Z0-9]+)\.([a-zA-Z0-9]+)$").unwrap());
|
||||||
|
let caps = RE.captures(s);
|
||||||
|
if let Some(caps) = caps {
|
||||||
|
let vendor = &caps[1];
|
||||||
|
let component = &caps[2];
|
||||||
|
Ok(Self::new(vendor, component))
|
||||||
|
} else {
|
||||||
|
Err(ParseProgIdError::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for LosseProgId {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "{},{}", self.binary.to_str().unwrap(), self.index)
|
write!(f, "{}.{}", self.vendor, self.component)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ProgId exactly follows `[Vendor or Application].[Component].[Version]` format.
|
||||||
|
pub struct StrictProgId {
|
||||||
|
vendor: String,
|
||||||
|
component: String,
|
||||||
|
version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StrictProgId {
|
||||||
|
pub fn new(vendor: &str, component: &str, version: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
vendor: vendor.to_string(),
|
||||||
|
component: component.to_string(),
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_vendor(&self) -> &str {
|
||||||
|
&self.vendor
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_component(&self) -> &str {
|
||||||
|
&self.component
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_version(&self) -> u32 {
|
||||||
|
self.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for StrictProgId {
|
||||||
|
type Err = ParseProgIdError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
static RE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^([a-zA-Z0-9]+)\.([a-zA-Z0-9]+)\.([0-9]+)$").unwrap());
|
||||||
|
let caps = RE.captures(s);
|
||||||
|
if let Some(caps) = caps {
|
||||||
|
let vendor = &caps[1];
|
||||||
|
let component = &caps[2];
|
||||||
|
let version = caps[3]
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| ParseProgIdError::new())?;
|
||||||
|
Ok(Self::new(vendor, component, version))
|
||||||
|
} else {
|
||||||
|
Err(ParseProgIdError::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for StrictProgId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}.{}.{}", self.vendor, self.component, self.version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
// /// The struct representing an Windows acceptable Prgram ID,
|
|
||||||
// /// which looks like `Program.Document.2`
|
|
||||||
// pub struct ProgId {
|
|
||||||
// inner: String,
|
|
||||||
// }
|
|
||||||
|
|
||||||
// impl ProgId {
|
|
||||||
// pub fn new(prog_id: &str) -> Self {
|
|
||||||
// Self {
|
|
||||||
// inner: prog_id.to_string(),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// region: Program
|
// region: Program
|
||||||
|
|
||||||
// /// The struct representing a complete Win32 program.
|
// /// The struct representing a complete Win32 program.
|
||||||
|
Reference in New Issue
Block a user