mirror of
https://github.com/asterinas/asterinas.git
synced 2025-06-22 08:53:29 +00:00
Implement pseudo terminal
This commit is contained in:
committed by
Tate, Hongliang Tian
parent
a042da1847
commit
f802ff40c5
@ -7,6 +7,7 @@ mod zero;
|
||||
|
||||
use crate::fs::device::{add_node, Device, DeviceId, DeviceType};
|
||||
use crate::prelude::*;
|
||||
pub use pty::{PtyMaster, PtySlave};
|
||||
pub use random::Random;
|
||||
pub use urandom::Urandom;
|
||||
|
||||
|
261
services/libs/jinux-std/src/device/pty/master.rs
Normal file
261
services/libs/jinux-std/src/device/pty/master.rs
Normal file
@ -0,0 +1,261 @@
|
||||
use crate::{
|
||||
fs::{
|
||||
file_handle::FileLike,
|
||||
fs_resolver::FsPath,
|
||||
utils::{AccessMode, Inode, InodeMode, IoEvents, IoctlCmd, Poller},
|
||||
},
|
||||
prelude::*,
|
||||
util::{read_val_from_user, write_val_to_user},
|
||||
};
|
||||
use alloc::format;
|
||||
use jinux_frame::sync::SpinLock;
|
||||
use ringbuf::{ring_buffer::RbBase, HeapRb, Rb};
|
||||
|
||||
use crate::{device::tty::line_discipline::LineDiscipline, fs::utils::Pollee};
|
||||
|
||||
use super::slave::PtySlave;
|
||||
|
||||
const PTS_DIR: &str = "/dev/pts";
|
||||
const BUFFER_CAPACITY: usize = 4096;
|
||||
|
||||
/// Pesudo terminal master.
|
||||
/// Internally, it has two buffers.
|
||||
/// One is inside ldisc, which is written by master and read by slave,
|
||||
/// the other is a ring buffer, which is written by slave and read by master.
|
||||
pub struct PtyMaster {
|
||||
ptmx: Arc<dyn Inode>,
|
||||
index: usize,
|
||||
ldisc: LineDiscipline,
|
||||
master_buffer: SpinLock<HeapRb<u8>>,
|
||||
/// The state of master buffer
|
||||
pollee: Pollee,
|
||||
}
|
||||
|
||||
impl PtyMaster {
|
||||
pub fn new_pair(index: u32, ptmx: Arc<dyn Inode>) -> Result<(Arc<PtyMaster>, Arc<PtySlave>)> {
|
||||
debug!("allocate pty index = {}", index);
|
||||
let master = Arc::new(PtyMaster {
|
||||
ptmx,
|
||||
index: index as usize,
|
||||
master_buffer: SpinLock::new(HeapRb::new(BUFFER_CAPACITY)),
|
||||
pollee: Pollee::new(IoEvents::OUT),
|
||||
ldisc: LineDiscipline::new(),
|
||||
});
|
||||
let slave = Arc::new(PtySlave::new(master.clone()));
|
||||
Ok((master, slave))
|
||||
}
|
||||
|
||||
pub fn index(&self) -> usize {
|
||||
self.index
|
||||
}
|
||||
|
||||
pub fn ptmx(&self) -> &Arc<dyn Inode> {
|
||||
&self.ptmx
|
||||
}
|
||||
|
||||
pub(super) fn slave_push_char(&self, item: u8) -> Result<()> {
|
||||
let mut buf = self.master_buffer.lock_irq_disabled();
|
||||
if buf.is_full() {
|
||||
return_errno_with_message!(Errno::EIO, "the buffer is full");
|
||||
}
|
||||
// Unwrap safety: the buf is not full, so push will always succeed.
|
||||
buf.push(item).unwrap();
|
||||
self.update_state(&buf);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn slave_read(&self, buf: &mut [u8]) -> Result<usize> {
|
||||
self.ldisc.read(buf)
|
||||
}
|
||||
|
||||
pub(super) fn slave_poll(&self, mask: IoEvents, poller: Option<&Poller>) -> IoEvents {
|
||||
let poll_out_mask = mask & IoEvents::OUT;
|
||||
let poll_in_mask = mask & IoEvents::IN;
|
||||
|
||||
loop {
|
||||
let mut poll_status = IoEvents::empty();
|
||||
|
||||
if !poll_in_mask.is_empty() {
|
||||
let poll_in_status = self.ldisc.poll(poll_in_mask, poller);
|
||||
poll_status |= poll_in_status;
|
||||
}
|
||||
|
||||
if !poll_out_mask.is_empty() {
|
||||
let poll_out_status = self.pollee.poll(poll_out_mask, poller);
|
||||
poll_status |= poll_out_status;
|
||||
}
|
||||
|
||||
if !poll_status.is_empty() || poller.is_none() {
|
||||
return poll_status;
|
||||
}
|
||||
|
||||
poller.unwrap().wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn update_state(&self, buf: &HeapRb<u8>) {
|
||||
if buf.is_full() {
|
||||
self.pollee.del_events(IoEvents::OUT);
|
||||
} else {
|
||||
self.pollee.add_events(IoEvents::OUT);
|
||||
}
|
||||
|
||||
if buf.is_empty() {
|
||||
self.pollee.del_events(IoEvents::IN)
|
||||
} else {
|
||||
self.pollee.add_events(IoEvents::IN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FileLike for PtyMaster {
|
||||
fn read(&self, buf: &mut [u8]) -> Result<usize> {
|
||||
// TODO: deal with nonblocking read
|
||||
if buf.len() == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let poller = Poller::new();
|
||||
loop {
|
||||
let mut master_buf = self.master_buffer.lock_irq_disabled();
|
||||
|
||||
if master_buf.is_empty() {
|
||||
self.update_state(&master_buf);
|
||||
let events = self.pollee.poll(IoEvents::IN, Some(&poller));
|
||||
if !events.contains(IoEvents::IN) {
|
||||
drop(master_buf);
|
||||
poller.wait();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let read_len = master_buf.len().min(buf.len());
|
||||
master_buf.pop_slice(&mut buf[..read_len]);
|
||||
self.update_state(&master_buf);
|
||||
return Ok(read_len);
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, buf: &[u8]) -> Result<usize> {
|
||||
let mut master_buf = self.master_buffer.lock();
|
||||
|
||||
if self.ldisc.termios().contain_echo() && master_buf.len() + buf.len() > BUFFER_CAPACITY {
|
||||
return_errno_with_message!(
|
||||
Errno::EIO,
|
||||
"the written bytes exceeds the master buf capacity"
|
||||
);
|
||||
}
|
||||
|
||||
for item in buf {
|
||||
self.ldisc.push_char(*item, |content| {
|
||||
for byte in content.as_bytes() {
|
||||
// Unwrap safety: the master buf is ensured to have enough space.
|
||||
master_buf.push(*byte).unwrap();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.update_state(&master_buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn ioctl(&self, cmd: IoctlCmd, arg: usize) -> Result<i32> {
|
||||
match cmd {
|
||||
IoctlCmd::TCGETS => {
|
||||
let termios = self.ldisc.termios();
|
||||
write_val_to_user(arg, &termios)?;
|
||||
Ok(0)
|
||||
}
|
||||
IoctlCmd::TCSETS => {
|
||||
let termios = read_val_from_user(arg)?;
|
||||
self.ldisc.set_termios(termios);
|
||||
Ok(0)
|
||||
}
|
||||
IoctlCmd::TIOCSPTLCK => {
|
||||
// TODO: lock/unlock pty
|
||||
Ok(0)
|
||||
}
|
||||
IoctlCmd::TIOCGPTN => {
|
||||
let idx = self.index() as u32;
|
||||
write_val_to_user(arg, &idx)?;
|
||||
Ok(0)
|
||||
}
|
||||
IoctlCmd::TIOCGPTPEER => {
|
||||
let current = current!();
|
||||
|
||||
// TODO: deal with open options
|
||||
let slave = {
|
||||
let slave_name = format!("{}/{}", PTS_DIR, self.index());
|
||||
let fs_path = FsPath::try_from(slave_name.as_str())?;
|
||||
|
||||
let inode_handle = {
|
||||
let fs = current.fs().read();
|
||||
let flags = AccessMode::O_RDWR as u32;
|
||||
let mode = (InodeMode::S_IRUSR | InodeMode::S_IWUSR).bits();
|
||||
fs.open(&fs_path, flags, mode)?
|
||||
};
|
||||
Arc::new(inode_handle)
|
||||
};
|
||||
|
||||
let fd = {
|
||||
let mut file_table = current.file_table().lock();
|
||||
file_table.insert(slave)
|
||||
};
|
||||
Ok(fd)
|
||||
}
|
||||
IoctlCmd::TIOCGWINSZ => Ok(0),
|
||||
IoctlCmd::TIOCSCTTY => {
|
||||
// TODO
|
||||
let foreground = {
|
||||
let current = current!();
|
||||
let process_group = current.process_group().lock();
|
||||
process_group.clone()
|
||||
};
|
||||
self.ldisc.set_fg(foreground);
|
||||
Ok(0)
|
||||
}
|
||||
IoctlCmd::TIOCGPGRP => {
|
||||
let Some(fg_pgid) = self.ldisc.fg_pgid() else {
|
||||
return_errno_with_message!(
|
||||
Errno::ESRCH,
|
||||
"the foreground process group does not exist"
|
||||
);
|
||||
};
|
||||
write_val_to_user(arg, &fg_pgid)?;
|
||||
Ok(0)
|
||||
}
|
||||
IoctlCmd::TIOCNOTTY => {
|
||||
self.ldisc.set_fg(Weak::new());
|
||||
Ok(0)
|
||||
}
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&self, mask: IoEvents, poller: Option<&Poller>) -> IoEvents {
|
||||
let poll_out_mask = mask & IoEvents::OUT;
|
||||
let poll_in_mask = mask & IoEvents::IN;
|
||||
|
||||
loop {
|
||||
let _master_buf = self.master_buffer.lock_irq_disabled();
|
||||
|
||||
let mut poll_status = IoEvents::empty();
|
||||
|
||||
if !poll_in_mask.is_empty() {
|
||||
let poll_in_status = self.pollee.poll(poll_in_mask, poller);
|
||||
poll_status |= poll_in_status;
|
||||
}
|
||||
|
||||
if !poll_out_mask.is_empty() {
|
||||
let poll_out_status = self.ldisc.poll(poll_out_mask, poller);
|
||||
poll_status |= poll_out_status;
|
||||
}
|
||||
|
||||
if !poll_status.is_empty() || poller.is_none() {
|
||||
return poll_status;
|
||||
}
|
||||
|
||||
poller.unwrap().wait();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,9 @@
|
||||
mod master;
|
||||
mod slave;
|
||||
|
||||
pub use master::PtyMaster;
|
||||
pub use slave::PtySlave;
|
||||
|
||||
use crate::fs::{
|
||||
devpts::DevPts,
|
||||
fs_resolver::{FsPath, FsResolver},
|
65
services/libs/jinux-std/src/device/pty/slave.rs
Normal file
65
services/libs/jinux-std/src/device/pty/slave.rs
Normal file
@ -0,0 +1,65 @@
|
||||
use crate::fs::device::{Device, DeviceId, DeviceType};
|
||||
use crate::fs::file_handle::FileLike;
|
||||
use crate::fs::utils::{IoEvents, IoctlCmd, Poller};
|
||||
use crate::prelude::*;
|
||||
|
||||
use super::master::PtyMaster;
|
||||
|
||||
pub struct PtySlave(Arc<PtyMaster>);
|
||||
|
||||
impl PtySlave {
|
||||
pub fn new(master: Arc<PtyMaster>) -> Self {
|
||||
PtySlave(master)
|
||||
}
|
||||
|
||||
pub fn index(&self) -> usize {
|
||||
self.0.index()
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for PtySlave {
|
||||
fn type_(&self) -> DeviceType {
|
||||
DeviceType::CharDevice
|
||||
}
|
||||
|
||||
fn id(&self) -> crate::fs::device::DeviceId {
|
||||
DeviceId::new(88, self.index() as u32)
|
||||
}
|
||||
|
||||
fn read(&self, buf: &mut [u8]) -> Result<usize> {
|
||||
self.0.slave_read(buf)
|
||||
}
|
||||
|
||||
fn write(&self, buf: &[u8]) -> Result<usize> {
|
||||
for ch in buf {
|
||||
// do we need to add '\r' here?
|
||||
if *ch == b'\n' {
|
||||
self.0.slave_push_char(b'\r')?;
|
||||
self.0.slave_push_char(b'\n')?;
|
||||
} else {
|
||||
self.0.slave_push_char(*ch)?;
|
||||
}
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn ioctl(&self, cmd: IoctlCmd, arg: usize) -> Result<i32> {
|
||||
match cmd {
|
||||
IoctlCmd::TCGETS | IoctlCmd::TCSETS | IoctlCmd::TIOCGPGRP => self.0.ioctl(cmd, arg),
|
||||
IoctlCmd::TIOCGWINSZ => Ok(0),
|
||||
IoctlCmd::TIOCSCTTY => {
|
||||
// TODO:
|
||||
Ok(0)
|
||||
}
|
||||
IoctlCmd::TIOCNOTTY => {
|
||||
// TODO:
|
||||
Ok(0)
|
||||
}
|
||||
_ => Ok(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll(&self, mask: IoEvents, poller: Option<&Poller>) -> IoEvents {
|
||||
self.0.slave_poll(mask, poller)
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@ use crate::process::process_group::ProcessGroup;
|
||||
use crate::process::signal::constants::{SIGINT, SIGQUIT};
|
||||
use crate::{
|
||||
prelude::*,
|
||||
process::{process_table, signal::signals::kernel::KernelSignal, Pgid},
|
||||
process::{signal::signals::kernel::KernelSignal, Pgid},
|
||||
};
|
||||
use alloc::format;
|
||||
use jinux_frame::trap::disable_local;
|
||||
@ -77,7 +77,7 @@ impl LineDiscipline {
|
||||
}
|
||||
|
||||
/// Push char to line discipline.
|
||||
pub fn push_char(&self, mut item: u8, echo_callback: fn(&str)) {
|
||||
pub fn push_char<F: FnMut(&str)>(&self, mut item: u8, echo_callback: F) {
|
||||
let termios = self.termios.lock_irq_disabled();
|
||||
if termios.contains_icrnl() && item == b'\r' {
|
||||
item = b'\n'
|
||||
@ -162,7 +162,7 @@ impl LineDiscipline {
|
||||
}
|
||||
|
||||
// TODO: respect output flags
|
||||
fn output_char(&self, item: u8, termios: &KernelTermios, echo_callback: fn(&str)) {
|
||||
fn output_char<F: FnMut(&str)>(&self, item: u8, termios: &KernelTermios, mut echo_callback: F) {
|
||||
match item {
|
||||
b'\n' => echo_callback("\n"),
|
||||
b'\r' => echo_callback("\r\n"),
|
||||
|
@ -6,7 +6,7 @@ use super::*;
|
||||
use crate::fs::utils::{IoEvents, IoctlCmd, Poller};
|
||||
use crate::prelude::*;
|
||||
use crate::process::process_group::ProcessGroup;
|
||||
use crate::process::{process_table, Pgid};
|
||||
use crate::process::process_table;
|
||||
use crate::util::{read_val_from_user, write_val_to_user};
|
||||
|
||||
pub mod driver;
|
||||
|
@ -1,7 +1,9 @@
|
||||
use crate::prelude::*;
|
||||
use crate::{fs::file_handle::FileLike, prelude::*};
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::device::PtyMaster;
|
||||
|
||||
/// Pty master inode for the master device.
|
||||
pub struct PtyMasterInode(Arc<PtyMaster>);
|
||||
|
||||
@ -14,8 +16,10 @@ impl PtyMasterInode {
|
||||
impl Drop for PtyMasterInode {
|
||||
fn drop(&mut self) {
|
||||
// Remove the slave from fs.
|
||||
let index = self.0.slave_index();
|
||||
let _ = self.0.ptmx().devpts().remove_slave(index);
|
||||
let index = self.0.index();
|
||||
let fs = self.0.ptmx().fs();
|
||||
let devpts = fs.downcast_ref::<DevPts>().unwrap();
|
||||
devpts.remove_slave(index);
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,52 +81,6 @@ impl Inode for PtyMasterInode {
|
||||
}
|
||||
|
||||
fn fs(&self) -> Arc<dyn FileSystem> {
|
||||
self.0.ptmx().devpts()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: implement real pty master.
|
||||
pub struct PtyMaster {
|
||||
slave_index: u32,
|
||||
ptmx: Arc<Ptmx>,
|
||||
}
|
||||
|
||||
impl PtyMaster {
|
||||
pub fn new(slave_index: u32, ptmx: Arc<Ptmx>) -> Arc<Self> {
|
||||
Arc::new(Self { slave_index, ptmx })
|
||||
}
|
||||
|
||||
pub fn slave_index(&self) -> u32 {
|
||||
self.slave_index
|
||||
}
|
||||
|
||||
fn ptmx(&self) -> &Ptmx {
|
||||
&self.ptmx
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for PtyMaster {
|
||||
fn type_(&self) -> DeviceType {
|
||||
self.ptmx.device_type()
|
||||
}
|
||||
|
||||
fn id(&self) -> DeviceId {
|
||||
self.ptmx.device_id()
|
||||
}
|
||||
|
||||
fn read(&self, buf: &mut [u8]) -> Result<usize> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn write(&self, buf: &[u8]) -> Result<usize> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn ioctl(&self, cmd: IoctlCmd, arg: usize) -> Result<i32> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn poll(&self, mask: IoEvents, poller: Option<&Poller>) -> IoEvents {
|
||||
todo!();
|
||||
self.0.ptmx().fs()
|
||||
}
|
||||
}
|
||||
|
@ -9,9 +9,10 @@ use core::time::Duration;
|
||||
use jinux_frame::vm::VmFrame;
|
||||
use jinux_util::{id_allocator::IdAlloc, slot_vec::SlotVec};
|
||||
|
||||
use self::master::{PtyMaster, PtyMasterInode};
|
||||
use self::master::PtyMasterInode;
|
||||
use self::ptmx::Ptmx;
|
||||
use self::slave::{PtySlave, PtySlaveInode};
|
||||
use self::slave::PtySlaveInode;
|
||||
use crate::device::PtyMaster;
|
||||
|
||||
mod master;
|
||||
mod ptmx;
|
||||
@ -60,8 +61,7 @@ impl DevPts {
|
||||
.alloc()
|
||||
.ok_or_else(|| Error::with_message(Errno::EIO, "cannot alloc index"))?;
|
||||
|
||||
let master = PtyMaster::new(index as u32, self.root.ptmx.clone());
|
||||
let slave = PtySlave::new(master.clone());
|
||||
let (master, slave) = PtyMaster::new_pair(index as u32, self.root.ptmx.clone())?;
|
||||
|
||||
let master_inode = PtyMasterInode::new(master);
|
||||
let slave_inode = PtySlaveInode::new(slave, self.this.clone());
|
||||
@ -73,10 +73,10 @@ impl DevPts {
|
||||
/// Remove the slave from fs.
|
||||
///
|
||||
/// This is called when the master is being dropped.
|
||||
fn remove_slave(&self, index: u32) -> Option<Arc<PtySlaveInode>> {
|
||||
fn remove_slave(&self, index: usize) -> Option<Arc<PtySlaveInode>> {
|
||||
let removed_slave = self.root.remove_slave(&index.to_string());
|
||||
if removed_slave.is_some() {
|
||||
self.index_alloc.lock().free(index as usize);
|
||||
self.index_alloc.lock().free(index);
|
||||
}
|
||||
removed_slave
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ use crate::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::device::PtySlave;
|
||||
|
||||
/// Same major number with Linux, the minor number is the index of slave.
|
||||
const SLAVE_MAJOR_NUM: u32 = 3;
|
||||
|
||||
@ -88,44 +90,3 @@ impl Inode for PtySlaveInode {
|
||||
self.fs.upgrade().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: implement real pty slave.
|
||||
pub struct PtySlave {
|
||||
master: Arc<PtyMaster>,
|
||||
}
|
||||
|
||||
impl PtySlave {
|
||||
pub fn new(master: Arc<PtyMaster>) -> Arc<Self> {
|
||||
Arc::new(Self { master })
|
||||
}
|
||||
|
||||
pub fn index(&self) -> u32 {
|
||||
self.master.slave_index()
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for PtySlave {
|
||||
fn type_(&self) -> DeviceType {
|
||||
DeviceType::CharDevice
|
||||
}
|
||||
|
||||
fn id(&self) -> DeviceId {
|
||||
DeviceId::new(SLAVE_MAJOR_NUM, self.index())
|
||||
}
|
||||
|
||||
fn read(&self, buf: &mut [u8]) -> Result<usize> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn write(&self, buf: &[u8]) -> Result<usize> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn ioctl(&self, cmd: IoctlCmd, arg: usize) -> Result<i32> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
fn poll(&self, mask: IoEvents, poller: Option<&Poller>) -> IoEvents {
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
|
@ -3,18 +3,28 @@ use crate::prelude::*;
|
||||
#[repr(u32)]
|
||||
#[derive(Debug, Clone, Copy, TryFromInt)]
|
||||
pub enum IoctlCmd {
|
||||
// Get terminal attributes
|
||||
/// Get terminal attributes
|
||||
TCGETS = 0x5401,
|
||||
TCSETS = 0x5402,
|
||||
// Drain the output buffer and set attributes
|
||||
/// Drain the output buffer and set attributes
|
||||
TCSETSW = 0x5403,
|
||||
// Drain the output buffer, and discard pending input, and set attributes
|
||||
/// Drain the output buffer, and discard pending input, and set attributes
|
||||
TCSETSF = 0x5404,
|
||||
// Get the process group ID of the foreground process group on this terminal
|
||||
/// Make the given terminal the controlling terminal of the calling process.
|
||||
TIOCSCTTY = 0x540e,
|
||||
/// Get the process group ID of the foreground process group on this terminal
|
||||
TIOCGPGRP = 0x540f,
|
||||
// Set the foreground process group ID of this terminal.
|
||||
/// Set the foreground process group ID of this terminal.
|
||||
TIOCSPGRP = 0x5410,
|
||||
// Set window size
|
||||
/// Set window size
|
||||
TIOCGWINSZ = 0x5413,
|
||||
TIOCSWINSZ = 0x5414,
|
||||
/// the calling process gives up this controlling terminal
|
||||
TIOCNOTTY = 0x5422,
|
||||
/// Get Pty Number
|
||||
TIOCGPTN = 0x80045430,
|
||||
/// Lock/unlock Pty
|
||||
TIOCSPTLCK = 0x40045431,
|
||||
/// Safely open the slave
|
||||
TIOCGPTPEER = 0x40045441,
|
||||
}
|
||||
|
Reference in New Issue
Block a user