use super::users::{self, Role, UsersDocument};
use anyhow::{bail, Context as _, Result};
use camino::Utf8PathBuf;
use clap::{Args, Subcommand};
use std::io::IsTerminal;
#[derive(Debug, Args)]
pub struct UserArgs {
#[arg(long, value_name = "PATH", global = true, value_hint = clap::ValueHint::FilePath)]
users_file: Option<Utf8PathBuf>,
#[command(subcommand)]
action: UserAction,
}
#[derive(Debug, Subcommand)]
enum UserAction {
Add {
name: String,
#[arg(long, default_value = "admin", value_parser = role_parser())]
role: Role,
},
Passwd {
name: String,
},
Role {
name: String,
#[arg(value_parser = role_parser())]
role: Role,
},
Remove {
name: String,
},
List,
}
pub fn run_user(args: UserArgs) -> Result<()> {
let location = users::locate(args.users_file.as_deref())?;
let path = &location.path;
let existing = match std::fs::read_to_string(path) {
Ok(text) => Some(text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(err).with_context(|| format!("could not read {path}")),
};
let mut doc = UsersDocument::parse(existing.as_deref().unwrap_or_default())
.with_context(|| format!("invalid users file {path}"))?;
match args.action {
UserAction::List => {
if existing.is_none() {
eprintln!("No users file at {path}: sign-in is off.");
}
let users = doc.users()?;
let width = users.roles().map(|(name, _)| name.len()).max();
for (name, role) in users.roles() {
println!("{name:<width$} {role}", width = width.unwrap_or_default());
}
}
UserAction::Add { name, role } => {
users::validate_username(&name)?;
if doc.contains(&name) {
bail!(
"{name} already exists in {path}; use `cook server user passwd {name}` \
to change their password"
);
}
let hash = super::hash_password(&read_new_password()?)?;
doc.add(&name, &hash, role);
users::write_users_file(path, &doc.to_string())?;
println!("Added {name} to {path} as {role}");
if existing.is_none() {
println!("Restart cook server to turn sign-in on.");
}
}
UserAction::Passwd { name } => {
if !doc.contains(&name) {
bail!("there is no user {name} in {path}");
}
let hash = super::hash_password(&read_new_password()?)?;
doc.set_password(&name, &hash);
users::write_users_file(path, &doc.to_string())?;
println!("Changed the password of {name} in {path}");
}
UserAction::Role { name, role } => {
if !doc.contains(&name) {
bail!("there is no user {name} in {path}");
}
doc.set_role(&name, role)?;
users::write_users_file(path, &doc.to_string())?;
println!("{name} is now {role} in {path}");
}
UserAction::Remove { name } => {
if !doc.remove(&name) {
bail!("there is no user {name} in {path}");
}
users::write_users_file(path, &doc.to_string())?;
println!("Removed {name} from {path}");
if doc.names().is_empty() {
println!(
"No users left: nobody can make changes. Delete {path} and restart \
cook server to open it to everyone again."
);
}
}
}
Ok(())
}
fn role_parser() -> impl clap::builder::TypedValueParser<Value = Role> {
use clap::builder::TypedValueParser as _;
clap::builder::PossibleValuesParser::new(Role::ALL.map(Role::as_str))
.map(|name| name.parse::<Role>().expect("only listed roles get here"))
}
pub fn run_hash_password() -> Result<()> {
println!("{}", super::hash_password(&read_new_password()?)?);
Ok(())
}
fn read_new_password() -> Result<String> {
let password = if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() {
let term = console::Term::stderr();
term.write_str("Password: ")?;
let first = term.read_secure_line()?;
term.write_str("Repeat password: ")?;
let second = term.read_secure_line()?;
if first != second {
bail!("the passwords do not match");
}
first
} else {
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.context("could not read the password from standard input")?;
line.trim_end_matches(['\r', '\n']).to_string()
};
if password.is_empty() {
bail!("the password cannot be empty");
}
if password.len() > super::MAX_PASSWORD_LEN {
bail!(
"the password cannot be longer than {} bytes",
super::MAX_PASSWORD_LEN
);
}
Ok(password)
}