mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-11-06 08:07:34 +00:00
refactor(core): isolate nyaterm json session import
This commit is contained in:
@@ -5,9 +5,9 @@ use serde::Deserialize;
|
||||
|
||||
use crate::{ConnectionAuth, ConnectionType};
|
||||
|
||||
use super::nyaterm_json::parse_nyaterm_json_content;
|
||||
use super::{
|
||||
AppError, AppResult, PreparedJsonConnection, PreparedJsonImport, parse_nyaterm_json_content,
|
||||
read_text_file_limited,
|
||||
AppError, AppResult, PreparedJsonConnection, PreparedJsonImport, read_text_file_limited,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//! bookmarks, and Termius IndexedDB data.
|
||||
|
||||
use crate::{AiExecutionProfile, ConnectionAuth, ConnectionType, SavedPassword, SshKey};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
@@ -12,6 +11,7 @@ use thiserror::Error;
|
||||
mod electerm;
|
||||
mod finalshell;
|
||||
mod mobaxterm;
|
||||
mod nyaterm_json;
|
||||
mod securecrt;
|
||||
mod termius;
|
||||
mod windterm;
|
||||
@@ -80,174 +80,6 @@ impl std::fmt::Debug for PreparedSessionImport {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonImportFile {
|
||||
#[serde(default = "default_import_version")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
passwords: Vec<NyatermJsonPassword>,
|
||||
#[serde(default)]
|
||||
ssh_keys: Vec<NyatermJsonSshKey>,
|
||||
#[serde(default)]
|
||||
groups: Vec<NyatermJsonGroup>,
|
||||
#[serde(default)]
|
||||
sessions: Vec<NyatermJsonSession>,
|
||||
}
|
||||
|
||||
fn default_import_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonPassword {
|
||||
#[serde(rename = "ref")]
|
||||
ref_name: String,
|
||||
name: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonSshKey {
|
||||
#[serde(rename = "ref")]
|
||||
ref_name: String,
|
||||
name: String,
|
||||
private_key: String,
|
||||
#[serde(default)]
|
||||
certificate: Option<String>,
|
||||
#[serde(default)]
|
||||
passphrase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonGroup {
|
||||
path: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonSshAuth {
|
||||
mode: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
password_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
key_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum NyatermJsonSession {
|
||||
Ssh {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
host: String,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
port: u16,
|
||||
#[serde(default = "default_ssh_user")]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
auth: Option<NyatermJsonSshAuth>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
LocalTerminal {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
#[serde(default)]
|
||||
shell_path: String,
|
||||
#[serde(default)]
|
||||
shell_args: String,
|
||||
#[serde(default)]
|
||||
working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
Telnet {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
host: String,
|
||||
#[serde(default = "default_telnet_port")]
|
||||
port: u16,
|
||||
#[serde(default = "default_telnet_backspace_mode")]
|
||||
backspace_mode: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
Serial {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
port_name: String,
|
||||
#[serde(default = "default_serial_baud_rate")]
|
||||
baud_rate: u32,
|
||||
#[serde(default = "default_serial_data_bits")]
|
||||
data_bits: u8,
|
||||
#[serde(default = "default_serial_parity")]
|
||||
parity: String,
|
||||
#[serde(default = "default_serial_stop_bits")]
|
||||
stop_bits: String,
|
||||
#[serde(default = "default_serial_backspace_mode")]
|
||||
backspace_mode: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
fn default_ssh_user() -> String {
|
||||
"root".to_string()
|
||||
}
|
||||
|
||||
fn default_telnet_port() -> u16 {
|
||||
23
|
||||
}
|
||||
|
||||
fn default_telnet_backspace_mode() -> String {
|
||||
"del".to_string()
|
||||
}
|
||||
|
||||
fn default_serial_baud_rate() -> u32 {
|
||||
115_200
|
||||
}
|
||||
|
||||
fn default_serial_data_bits() -> u8 {
|
||||
8
|
||||
}
|
||||
|
||||
fn default_serial_parity() -> String {
|
||||
"none".to_string()
|
||||
}
|
||||
|
||||
fn default_serial_stop_bits() -> String {
|
||||
"1".to_string()
|
||||
}
|
||||
|
||||
fn default_serial_backspace_mode() -> String {
|
||||
"ctrl_h".to_string()
|
||||
}
|
||||
|
||||
/// Detect BOM (UTF-8/UTF-16) and decode accordingly; fall back to GBK.
|
||||
fn decode_bytes(raw: &[u8]) -> String {
|
||||
if let Some((enc, bom_len)) = encoding_rs::Encoding::for_bom(raw) {
|
||||
@@ -296,6 +128,17 @@ fn read_text_file_limited(path: impl AsRef<Path>, label: &str) -> AppResult<Stri
|
||||
.map_err(|error| AppError::Config(format!("{label} is not valid UTF-8: {error}")))
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ini_sections(content: &str) -> HashMap<String, HashMap<String, String>> {
|
||||
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
let mut current_section = String::new();
|
||||
@@ -318,445 +161,6 @@ fn parse_ini_sections(content: &str) -> HashMap<String, HashMap<String, String>>
|
||||
sections
|
||||
}
|
||||
|
||||
// NyaTerm JSON (.json)
|
||||
|
||||
fn parse_nyaterm_json_content(content: &str) -> AppResult<PreparedSessionImport> {
|
||||
let file: NyatermJsonImportFile = serde_json::from_str(content)
|
||||
.map_err(|e| AppError::Config(format!("Invalid NyaTerm JSON: {e}")))?;
|
||||
prepare_nyaterm_json_import(file)
|
||||
}
|
||||
|
||||
fn prepare_nyaterm_json_import(file: NyatermJsonImportFile) -> AppResult<PreparedSessionImport> {
|
||||
if file.version != 1 {
|
||||
return Err(AppError::Config(format!(
|
||||
"Unsupported NyaTerm JSON import version: {}",
|
||||
file.version
|
||||
)));
|
||||
}
|
||||
|
||||
let mut password_ref_map: HashMap<String, String> = HashMap::new();
|
||||
let mut passwords = Vec::new();
|
||||
for entry in file.passwords {
|
||||
let ref_name = required_string(entry.ref_name, "password ref", "passwords")?;
|
||||
if password_ref_map.contains_key(&ref_name) {
|
||||
return Err(AppError::Config(format!(
|
||||
"Duplicate password ref in import file: {ref_name}"
|
||||
)));
|
||||
}
|
||||
if entry.password.is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"Password entry '{ref_name}' cannot have an empty password"
|
||||
)));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
password_ref_map.insert(ref_name, id.clone());
|
||||
passwords.push(SavedPassword {
|
||||
id,
|
||||
name: required_string(entry.name, "password name", "passwords")?,
|
||||
password: Some(entry.password),
|
||||
has_password: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut key_ref_map: HashMap<String, String> = HashMap::new();
|
||||
let mut ssh_keys = Vec::new();
|
||||
for entry in file.ssh_keys {
|
||||
let ref_name = required_string(entry.ref_name, "ssh key ref", "ssh_keys")?;
|
||||
if key_ref_map.contains_key(&ref_name) {
|
||||
return Err(AppError::Config(format!(
|
||||
"Duplicate ssh key ref in import file: {ref_name}"
|
||||
)));
|
||||
}
|
||||
if entry.private_key.trim().is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"SSH key entry '{ref_name}' cannot have empty private_key"
|
||||
)));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
key_ref_map.insert(ref_name, id.clone());
|
||||
ssh_keys.push(SshKey {
|
||||
id,
|
||||
name: required_string(entry.name, "ssh key name", "ssh_keys")?,
|
||||
key: Some(entry.private_key),
|
||||
cert: normalize_optional_string(entry.certificate),
|
||||
passphrase: normalize_optional_string(entry.passphrase),
|
||||
key_file_path: None,
|
||||
cert_file_path: None,
|
||||
has_key_data: false,
|
||||
has_cert_data: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut groups = Vec::new();
|
||||
for group in file.groups {
|
||||
let path = normalize_required_group_path(group.path, "groups.path")?;
|
||||
if !groups.contains(&path) {
|
||||
groups.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut connections = Vec::new();
|
||||
for session in file.sessions {
|
||||
connections.push(prepare_nyaterm_json_session(
|
||||
session,
|
||||
&password_ref_map,
|
||||
&key_ref_map,
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(PreparedSessionImport {
|
||||
groups,
|
||||
passwords,
|
||||
ssh_keys,
|
||||
connections,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_nyaterm_json_session(
|
||||
session: NyatermJsonSession,
|
||||
password_ref_map: &HashMap<String, String>,
|
||||
key_ref_map: &HashMap<String, String>,
|
||||
) -> AppResult<PreparedSessionConnection> {
|
||||
match session {
|
||||
NyatermJsonSession::Ssh {
|
||||
name,
|
||||
group_path,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
auth,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
validate_port(port, "ssh session")?;
|
||||
let context = format!("ssh session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "ssh session")?,
|
||||
config: ConnectionType::Ssh {
|
||||
host: required_string(host, "host", &context)?,
|
||||
port,
|
||||
username: required_string(username, "username", &context)?,
|
||||
backspace_mode: "del".to_string(),
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
x11_forwarding: false,
|
||||
agent_endpoint: Default::default(),
|
||||
agent_forwarding: false,
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: Some(prepare_json_ssh_auth(
|
||||
auth,
|
||||
password_ref_map,
|
||||
key_ref_map,
|
||||
&context,
|
||||
)?),
|
||||
})
|
||||
}
|
||||
NyatermJsonSession::LocalTerminal {
|
||||
name,
|
||||
group_path,
|
||||
shell_path,
|
||||
shell_args,
|
||||
working_dir,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
let context = format!("local_terminal session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "local_terminal session")?,
|
||||
config: ConnectionType::LocalTerminal {
|
||||
shell_path: required_string(shell_path, "shell_path", &context)?,
|
||||
shell_args,
|
||||
working_dir: normalize_optional_string(working_dir),
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: None,
|
||||
})
|
||||
}
|
||||
NyatermJsonSession::Telnet {
|
||||
name,
|
||||
group_path,
|
||||
host,
|
||||
port,
|
||||
backspace_mode,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
validate_port(port, "telnet session")?;
|
||||
validate_backspace_mode(&backspace_mode, "telnet session")?;
|
||||
let context = format!("telnet session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "telnet session")?,
|
||||
config: ConnectionType::Telnet {
|
||||
host: required_string(host, "host", &context)?,
|
||||
port,
|
||||
username: String::new(),
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
backspace_mode,
|
||||
raw_tcp_cli: false,
|
||||
enter_mode: "cr".to_string(),
|
||||
local_echo: false,
|
||||
local_line_edit: false,
|
||||
force_character_at_a_time: false,
|
||||
send_naws: true,
|
||||
send_sga: true,
|
||||
auto_login: Default::default(),
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: None,
|
||||
})
|
||||
}
|
||||
NyatermJsonSession::Serial {
|
||||
name,
|
||||
group_path,
|
||||
port_name,
|
||||
baud_rate,
|
||||
data_bits,
|
||||
parity,
|
||||
stop_bits,
|
||||
backspace_mode,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
validate_serial_config(baud_rate, data_bits, &parity, &stop_bits, &backspace_mode)?;
|
||||
let context = format!("serial session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "serial session")?,
|
||||
config: ConnectionType::Serial {
|
||||
port_name: required_string(port_name, "port_name", &context)?,
|
||||
baud_rate,
|
||||
data_bits,
|
||||
parity,
|
||||
stop_bits,
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
backspace_mode,
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_json_ssh_auth(
|
||||
auth: Option<NyatermJsonSshAuth>,
|
||||
password_ref_map: &HashMap<String, String>,
|
||||
key_ref_map: &HashMap<String, String>,
|
||||
context: &str,
|
||||
) -> AppResult<ConnectionAuth> {
|
||||
let Some(auth) = auth else {
|
||||
return Ok(ConnectionAuth {
|
||||
mode: "none".to_string(),
|
||||
password_id: None,
|
||||
password: None,
|
||||
key_id: None,
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
});
|
||||
};
|
||||
|
||||
match auth.mode.trim() {
|
||||
"none" => {
|
||||
if auth.password.is_some() || auth.password_ref.is_some() || auth.key_ref.is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: auth.mode 'none' cannot include password, password_ref, or key_ref"
|
||||
)));
|
||||
}
|
||||
Ok(ConnectionAuth {
|
||||
mode: "none".to_string(),
|
||||
password_id: None,
|
||||
password: None,
|
||||
key_id: None,
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
})
|
||||
}
|
||||
"password" => {
|
||||
let has_password = auth
|
||||
.password
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let password_ref = normalize_optional_string(auth.password_ref);
|
||||
if has_password == password_ref.is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: password auth must include exactly one of password or password_ref"
|
||||
)));
|
||||
}
|
||||
let password_id = if let Some(ref_name) = password_ref {
|
||||
Some(password_ref_map.get(&ref_name).cloned().ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"{context}: password_ref '{ref_name}' was not found"
|
||||
))
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let password = auth.password;
|
||||
|
||||
Ok(ConnectionAuth {
|
||||
mode: "password".to_string(),
|
||||
password_id,
|
||||
password,
|
||||
key_id: None,
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
})
|
||||
}
|
||||
"key" => {
|
||||
if auth.password.is_some() || auth.password_ref.is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: key auth cannot include password or password_ref"
|
||||
)));
|
||||
}
|
||||
let key_ref = normalize_optional_string(auth.key_ref)
|
||||
.ok_or_else(|| AppError::Config(format!("{context}: key auth requires key_ref")))?;
|
||||
let key_id = key_ref_map.get(&key_ref).cloned().ok_or_else(|| {
|
||||
AppError::Config(format!("{context}: key_ref '{key_ref}' was not found"))
|
||||
})?;
|
||||
|
||||
Ok(ConnectionAuth {
|
||||
mode: "key".to_string(),
|
||||
password_id: None,
|
||||
password: None,
|
||||
key_id: Some(key_id),
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
})
|
||||
}
|
||||
mode => Err(AppError::Config(format!(
|
||||
"{context}: unsupported SSH auth mode '{mode}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string(value: String, field: &str, context: &str) -> AppResult<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::Config(format!("{context}: {field} is required")));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_group_path(path: Vec<String>, context: &str) -> AppResult<Vec<String>> {
|
||||
normalize_optional_group_path(path, context)?.ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"{context}: group path must contain at least one segment"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_group_path(
|
||||
path: Vec<String>,
|
||||
context: &str,
|
||||
) -> AppResult<Option<Vec<String>>> {
|
||||
if path.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut segments = Vec::new();
|
||||
for segment in path {
|
||||
let trimmed = segment.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: group path segments cannot be empty"
|
||||
)));
|
||||
}
|
||||
segments.push(trimmed.to_string());
|
||||
}
|
||||
Ok(Some(segments))
|
||||
}
|
||||
|
||||
fn validate_port(port: u16, context: &str) -> AppResult<()> {
|
||||
if port == 0 {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: port must be between 1 and 65535"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_backspace_mode(value: &str, context: &str) -> AppResult<()> {
|
||||
match value {
|
||||
"ctrl_h" | "del" => Ok(()),
|
||||
_ => Err(AppError::Config(format!(
|
||||
"{context}: backspace_mode must be 'ctrl_h' or 'del'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_serial_config(
|
||||
baud_rate: u32,
|
||||
data_bits: u8,
|
||||
parity: &str,
|
||||
stop_bits: &str,
|
||||
backspace_mode: &str,
|
||||
) -> AppResult<()> {
|
||||
if baud_rate == 0 {
|
||||
return Err(AppError::Config(
|
||||
"serial session: baud_rate must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
if !(5..=8).contains(&data_bits) {
|
||||
return Err(AppError::Config(
|
||||
"serial session: data_bits must be between 5 and 8".to_string(),
|
||||
));
|
||||
}
|
||||
match parity {
|
||||
"none" | "even" | "odd" => {}
|
||||
_ => {
|
||||
return Err(AppError::Config(
|
||||
"serial session: parity must be 'none', 'even', or 'odd'".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
match stop_bits {
|
||||
"1" | "1.5" | "2" => {}
|
||||
_ => {
|
||||
return Err(AppError::Config(
|
||||
"serial session: stop_bits must be '1', '1.5', or '2'".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
validate_backspace_mode(backspace_mode, "serial session")
|
||||
}
|
||||
|
||||
// Shared import persistence helpers
|
||||
|
||||
fn prepare_legacy_sessions(imported: Vec<ImportedSession>) -> PreparedSessionImport {
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{AiExecutionProfile, ConnectionAuth, ConnectionType, SavedPassword, SshKey};
|
||||
|
||||
use super::{
|
||||
AppError, AppResult, PreparedSessionConnection, PreparedSessionImport,
|
||||
normalize_optional_string,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonImportFile {
|
||||
#[serde(default = "default_import_version")]
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
passwords: Vec<NyatermJsonPassword>,
|
||||
#[serde(default)]
|
||||
ssh_keys: Vec<NyatermJsonSshKey>,
|
||||
#[serde(default)]
|
||||
groups: Vec<NyatermJsonGroup>,
|
||||
#[serde(default)]
|
||||
sessions: Vec<NyatermJsonSession>,
|
||||
}
|
||||
|
||||
fn default_import_version() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonPassword {
|
||||
#[serde(rename = "ref")]
|
||||
ref_name: String,
|
||||
name: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonSshKey {
|
||||
#[serde(rename = "ref")]
|
||||
ref_name: String,
|
||||
name: String,
|
||||
private_key: String,
|
||||
#[serde(default)]
|
||||
certificate: Option<String>,
|
||||
#[serde(default)]
|
||||
passphrase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonGroup {
|
||||
path: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NyatermJsonSshAuth {
|
||||
mode: String,
|
||||
#[serde(default)]
|
||||
password: Option<String>,
|
||||
#[serde(default)]
|
||||
password_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
key_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum NyatermJsonSession {
|
||||
Ssh {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
host: String,
|
||||
#[serde(default = "default_ssh_port")]
|
||||
port: u16,
|
||||
#[serde(default = "default_ssh_user")]
|
||||
username: String,
|
||||
#[serde(default)]
|
||||
auth: Option<NyatermJsonSshAuth>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
LocalTerminal {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
#[serde(default)]
|
||||
shell_path: String,
|
||||
#[serde(default)]
|
||||
shell_args: String,
|
||||
#[serde(default)]
|
||||
working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
Telnet {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
host: String,
|
||||
#[serde(default = "default_telnet_port")]
|
||||
port: u16,
|
||||
#[serde(default = "default_telnet_backspace_mode")]
|
||||
backspace_mode: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
Serial {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
group_path: Vec<String>,
|
||||
port_name: String,
|
||||
#[serde(default = "default_serial_baud_rate")]
|
||||
baud_rate: u32,
|
||||
#[serde(default = "default_serial_data_bits")]
|
||||
data_bits: u8,
|
||||
#[serde(default = "default_serial_parity")]
|
||||
parity: String,
|
||||
#[serde(default = "default_serial_stop_bits")]
|
||||
stop_bits: String,
|
||||
#[serde(default = "default_serial_backspace_mode")]
|
||||
backspace_mode: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
sort_order: i32,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_ssh_port() -> u16 {
|
||||
22
|
||||
}
|
||||
|
||||
fn default_ssh_user() -> String {
|
||||
"root".to_string()
|
||||
}
|
||||
|
||||
fn default_telnet_port() -> u16 {
|
||||
23
|
||||
}
|
||||
|
||||
fn default_telnet_backspace_mode() -> String {
|
||||
"del".to_string()
|
||||
}
|
||||
|
||||
fn default_serial_baud_rate() -> u32 {
|
||||
115_200
|
||||
}
|
||||
|
||||
fn default_serial_data_bits() -> u8 {
|
||||
8
|
||||
}
|
||||
|
||||
fn default_serial_parity() -> String {
|
||||
"none".to_string()
|
||||
}
|
||||
|
||||
fn default_serial_stop_bits() -> String {
|
||||
"1".to_string()
|
||||
}
|
||||
|
||||
fn default_serial_backspace_mode() -> String {
|
||||
"ctrl_h".to_string()
|
||||
}
|
||||
|
||||
// NyaTerm JSON (.json)
|
||||
|
||||
pub(super) fn parse_nyaterm_json_content(content: &str) -> AppResult<PreparedSessionImport> {
|
||||
let file: NyatermJsonImportFile = serde_json::from_str(content)
|
||||
.map_err(|e| AppError::Config(format!("Invalid NyaTerm JSON: {e}")))?;
|
||||
prepare_nyaterm_json_import(file)
|
||||
}
|
||||
|
||||
fn prepare_nyaterm_json_import(file: NyatermJsonImportFile) -> AppResult<PreparedSessionImport> {
|
||||
if file.version != 1 {
|
||||
return Err(AppError::Config(format!(
|
||||
"Unsupported NyaTerm JSON import version: {}",
|
||||
file.version
|
||||
)));
|
||||
}
|
||||
|
||||
let mut password_ref_map: HashMap<String, String> = HashMap::new();
|
||||
let mut passwords = Vec::new();
|
||||
for entry in file.passwords {
|
||||
let ref_name = required_string(entry.ref_name, "password ref", "passwords")?;
|
||||
if password_ref_map.contains_key(&ref_name) {
|
||||
return Err(AppError::Config(format!(
|
||||
"Duplicate password ref in import file: {ref_name}"
|
||||
)));
|
||||
}
|
||||
if entry.password.is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"Password entry '{ref_name}' cannot have an empty password"
|
||||
)));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
password_ref_map.insert(ref_name, id.clone());
|
||||
passwords.push(SavedPassword {
|
||||
id,
|
||||
name: required_string(entry.name, "password name", "passwords")?,
|
||||
password: Some(entry.password),
|
||||
has_password: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut key_ref_map: HashMap<String, String> = HashMap::new();
|
||||
let mut ssh_keys = Vec::new();
|
||||
for entry in file.ssh_keys {
|
||||
let ref_name = required_string(entry.ref_name, "ssh key ref", "ssh_keys")?;
|
||||
if key_ref_map.contains_key(&ref_name) {
|
||||
return Err(AppError::Config(format!(
|
||||
"Duplicate ssh key ref in import file: {ref_name}"
|
||||
)));
|
||||
}
|
||||
if entry.private_key.trim().is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"SSH key entry '{ref_name}' cannot have empty private_key"
|
||||
)));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
key_ref_map.insert(ref_name, id.clone());
|
||||
ssh_keys.push(SshKey {
|
||||
id,
|
||||
name: required_string(entry.name, "ssh key name", "ssh_keys")?,
|
||||
key: Some(entry.private_key),
|
||||
cert: normalize_optional_string(entry.certificate),
|
||||
passphrase: normalize_optional_string(entry.passphrase),
|
||||
key_file_path: None,
|
||||
cert_file_path: None,
|
||||
has_key_data: false,
|
||||
has_cert_data: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut groups = Vec::new();
|
||||
for group in file.groups {
|
||||
let path = normalize_required_group_path(group.path, "groups.path")?;
|
||||
if !groups.contains(&path) {
|
||||
groups.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut connections = Vec::new();
|
||||
for session in file.sessions {
|
||||
connections.push(prepare_nyaterm_json_session(
|
||||
session,
|
||||
&password_ref_map,
|
||||
&key_ref_map,
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(PreparedSessionImport {
|
||||
groups,
|
||||
passwords,
|
||||
ssh_keys,
|
||||
connections,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_nyaterm_json_session(
|
||||
session: NyatermJsonSession,
|
||||
password_ref_map: &HashMap<String, String>,
|
||||
key_ref_map: &HashMap<String, String>,
|
||||
) -> AppResult<PreparedSessionConnection> {
|
||||
match session {
|
||||
NyatermJsonSession::Ssh {
|
||||
name,
|
||||
group_path,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
auth,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
validate_port(port, "ssh session")?;
|
||||
let context = format!("ssh session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "ssh session")?,
|
||||
config: ConnectionType::Ssh {
|
||||
host: required_string(host, "host", &context)?,
|
||||
port,
|
||||
username: required_string(username, "username", &context)?,
|
||||
backspace_mode: "del".to_string(),
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
x11_forwarding: false,
|
||||
agent_endpoint: Default::default(),
|
||||
agent_forwarding: false,
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: Some(prepare_json_ssh_auth(
|
||||
auth,
|
||||
password_ref_map,
|
||||
key_ref_map,
|
||||
&context,
|
||||
)?),
|
||||
})
|
||||
}
|
||||
NyatermJsonSession::LocalTerminal {
|
||||
name,
|
||||
group_path,
|
||||
shell_path,
|
||||
shell_args,
|
||||
working_dir,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
let context = format!("local_terminal session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "local_terminal session")?,
|
||||
config: ConnectionType::LocalTerminal {
|
||||
shell_path: required_string(shell_path, "shell_path", &context)?,
|
||||
shell_args,
|
||||
working_dir: normalize_optional_string(working_dir),
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: None,
|
||||
})
|
||||
}
|
||||
NyatermJsonSession::Telnet {
|
||||
name,
|
||||
group_path,
|
||||
host,
|
||||
port,
|
||||
backspace_mode,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
validate_port(port, "telnet session")?;
|
||||
validate_backspace_mode(&backspace_mode, "telnet session")?;
|
||||
let context = format!("telnet session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "telnet session")?,
|
||||
config: ConnectionType::Telnet {
|
||||
host: required_string(host, "host", &context)?,
|
||||
port,
|
||||
username: String::new(),
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
backspace_mode,
|
||||
raw_tcp_cli: false,
|
||||
enter_mode: "cr".to_string(),
|
||||
local_echo: false,
|
||||
local_line_edit: false,
|
||||
force_character_at_a_time: false,
|
||||
send_naws: true,
|
||||
send_sga: true,
|
||||
auto_login: Default::default(),
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: None,
|
||||
})
|
||||
}
|
||||
NyatermJsonSession::Serial {
|
||||
name,
|
||||
group_path,
|
||||
port_name,
|
||||
baud_rate,
|
||||
data_bits,
|
||||
parity,
|
||||
stop_bits,
|
||||
backspace_mode,
|
||||
description,
|
||||
sort_order,
|
||||
icon,
|
||||
} => {
|
||||
validate_serial_config(baud_rate, data_bits, &parity, &stop_bits, &backspace_mode)?;
|
||||
let context = format!("serial session '{name}'");
|
||||
Ok(PreparedSessionConnection {
|
||||
name: required_string(name, "name", "serial session")?,
|
||||
config: ConnectionType::Serial {
|
||||
port_name: required_string(port_name, "port_name", &context)?,
|
||||
baud_rate,
|
||||
data_bits,
|
||||
parity,
|
||||
stop_bits,
|
||||
ai_execution_profile: AiExecutionProfile::Auto,
|
||||
backspace_mode,
|
||||
encoding: String::new(),
|
||||
},
|
||||
group_path: normalize_optional_group_path(group_path, &context)?,
|
||||
description: normalize_optional_string(description),
|
||||
sort_order,
|
||||
icon: normalize_optional_string(icon),
|
||||
auth: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_json_ssh_auth(
|
||||
auth: Option<NyatermJsonSshAuth>,
|
||||
password_ref_map: &HashMap<String, String>,
|
||||
key_ref_map: &HashMap<String, String>,
|
||||
context: &str,
|
||||
) -> AppResult<ConnectionAuth> {
|
||||
let Some(auth) = auth else {
|
||||
return Ok(ConnectionAuth {
|
||||
mode: "none".to_string(),
|
||||
password_id: None,
|
||||
password: None,
|
||||
key_id: None,
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
});
|
||||
};
|
||||
|
||||
match auth.mode.trim() {
|
||||
"none" => {
|
||||
if auth.password.is_some() || auth.password_ref.is_some() || auth.key_ref.is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: auth.mode 'none' cannot include password, password_ref, or key_ref"
|
||||
)));
|
||||
}
|
||||
Ok(ConnectionAuth {
|
||||
mode: "none".to_string(),
|
||||
password_id: None,
|
||||
password: None,
|
||||
key_id: None,
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
})
|
||||
}
|
||||
"password" => {
|
||||
let has_password = auth
|
||||
.password
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
let password_ref = normalize_optional_string(auth.password_ref);
|
||||
if has_password == password_ref.is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: password auth must include exactly one of password or password_ref"
|
||||
)));
|
||||
}
|
||||
let password_id = if let Some(ref_name) = password_ref {
|
||||
Some(password_ref_map.get(&ref_name).cloned().ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"{context}: password_ref '{ref_name}' was not found"
|
||||
))
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let password = auth.password;
|
||||
|
||||
Ok(ConnectionAuth {
|
||||
mode: "password".to_string(),
|
||||
password_id,
|
||||
password,
|
||||
key_id: None,
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
})
|
||||
}
|
||||
"key" => {
|
||||
if auth.password.is_some() || auth.password_ref.is_some() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: key auth cannot include password or password_ref"
|
||||
)));
|
||||
}
|
||||
let key_ref = normalize_optional_string(auth.key_ref)
|
||||
.ok_or_else(|| AppError::Config(format!("{context}: key auth requires key_ref")))?;
|
||||
let key_id = key_ref_map.get(&key_ref).cloned().ok_or_else(|| {
|
||||
AppError::Config(format!("{context}: key_ref '{key_ref}' was not found"))
|
||||
})?;
|
||||
|
||||
Ok(ConnectionAuth {
|
||||
mode: "key".to_string(),
|
||||
password_id: None,
|
||||
password: None,
|
||||
key_id: Some(key_id),
|
||||
otp_id: None,
|
||||
auto_fill_otp: false,
|
||||
has_password: false,
|
||||
})
|
||||
}
|
||||
mode => Err(AppError::Config(format!(
|
||||
"{context}: unsupported SSH auth mode '{mode}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn required_string(value: String, field: &str, context: &str) -> AppResult<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::Config(format!("{context}: {field} is required")));
|
||||
}
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn normalize_required_group_path(path: Vec<String>, context: &str) -> AppResult<Vec<String>> {
|
||||
normalize_optional_group_path(path, context)?.ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"{context}: group path must contain at least one segment"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_group_path(
|
||||
path: Vec<String>,
|
||||
context: &str,
|
||||
) -> AppResult<Option<Vec<String>>> {
|
||||
if path.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut segments = Vec::new();
|
||||
for segment in path {
|
||||
let trimmed = segment.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: group path segments cannot be empty"
|
||||
)));
|
||||
}
|
||||
segments.push(trimmed.to_string());
|
||||
}
|
||||
Ok(Some(segments))
|
||||
}
|
||||
|
||||
fn validate_port(port: u16, context: &str) -> AppResult<()> {
|
||||
if port == 0 {
|
||||
return Err(AppError::Config(format!(
|
||||
"{context}: port must be between 1 and 65535"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_backspace_mode(value: &str, context: &str) -> AppResult<()> {
|
||||
match value {
|
||||
"ctrl_h" | "del" => Ok(()),
|
||||
_ => Err(AppError::Config(format!(
|
||||
"{context}: backspace_mode must be 'ctrl_h' or 'del'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_serial_config(
|
||||
baud_rate: u32,
|
||||
data_bits: u8,
|
||||
parity: &str,
|
||||
stop_bits: &str,
|
||||
backspace_mode: &str,
|
||||
) -> AppResult<()> {
|
||||
if baud_rate == 0 {
|
||||
return Err(AppError::Config(
|
||||
"serial session: baud_rate must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
if !(5..=8).contains(&data_bits) {
|
||||
return Err(AppError::Config(
|
||||
"serial session: data_bits must be between 5 and 8".to_string(),
|
||||
));
|
||||
}
|
||||
match parity {
|
||||
"none" | "even" | "odd" => {}
|
||||
_ => {
|
||||
return Err(AppError::Config(
|
||||
"serial session: parity must be 'none', 'even', or 'odd'".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
match stop_bits {
|
||||
"1" | "1.5" | "2" => {}
|
||||
_ => {
|
||||
return Err(AppError::Config(
|
||||
"serial session: stop_bits must be '1', '1.5', or '2'".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
validate_backspace_mode(backspace_mode, "serial session")
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::{
|
||||
ConnectionType, parse_nyaterm_json_content, prepare_session_import, read_file_limited,
|
||||
};
|
||||
use super::{ConnectionType, prepare_session_import, read_file_limited};
|
||||
use super::{
|
||||
mobaxterm::parse_moba_entry,
|
||||
nyaterm_json::parse_nyaterm_json_content,
|
||||
windterm::{parse_windterm_content, parse_windterm_target},
|
||||
xshell::parse_xsh_content,
|
||||
};
|
||||
|
||||
@@ -280,10 +280,11 @@ these as staged extraction candidates, not as formatting-only refactor targets.
|
||||
|
||||
## Completed
|
||||
|
||||
- Xshell archive, MobaXterm bookmark and WindTerm session parsing now live in
|
||||
their respective `nyaterm-core::session_import` format modules. The
|
||||
session-import facade retains dispatch and shared bounded helpers, while ZIP
|
||||
entry limits, encoding handling, group derivation and authentication
|
||||
- Xshell archive, MobaXterm bookmark, WindTerm session and NyaTerm JSON parsing
|
||||
now live in their respective `nyaterm-core::session_import` format modules.
|
||||
The session-import facade retains public result contracts, dispatch and
|
||||
shared bounded helpers; JSON defaults and credential-reference validation,
|
||||
ZIP entry limits, encoding handling, group derivation and authentication
|
||||
compatibility remain covered by the existing parser tests.
|
||||
- Composite Rust domains now consistently use directory roots (`mod.rs`) when
|
||||
they own child modules, and `nyaterm-core::models::workspace` owns the
|
||||
|
||||
Reference in New Issue
Block a user