use clap::{Args, Subcommand};
#[derive(Debug, Subcommand)]
pub enum OperatorCommand {
#[command(subcommand)]
Quota(QuotaCommand),
#[command(subcommand)]
Account(AccountCommand),
#[command(subcommand)]
Invite(InviteCommand),
}
#[derive(Debug, Subcommand)]
pub enum QuotaCommand {
Reconcile(QuotaReconcileArgs),
}
#[derive(Debug, Args, Clone)]
pub struct QuotaReconcileArgs {
#[arg(required_unless_present = "all")]
pub pod_id: Option<String>,
#[arg(long, conflicts_with = "pod_id")]
pub all: bool,
#[arg(long, env = "JSS_STORAGE_ROOT", default_value = "./data")]
pub root: std::path::PathBuf,
#[arg(long, default_value_t = 0)]
pub default_limit: u64,
}
#[derive(Debug, Subcommand)]
pub enum AccountCommand {
Delete(AccountDeleteArgs),
}
#[derive(Debug, Args, Clone)]
pub struct AccountDeleteArgs {
pub user_id: String,
#[arg(long)]
pub yes: bool,
}
#[derive(Debug, Subcommand)]
pub enum InviteCommand {
Create(InviteCreateArgs),
}
#[derive(Debug, Args, Clone)]
pub struct InviteCreateArgs {
#[arg(short = 'u', long = "uses")]
pub uses: Option<u32>,
#[arg(long = "expires-in")]
pub expires_in: Option<String>,
#[arg(long = "base-url", default_value = "https://pod.invalid")]
pub base_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReconcileOutcome {
pub pod: String,
pub used_bytes: u64,
pub limit_bytes: u64,
}
#[cfg(feature = "quota")]
pub async fn run_quota_reconcile(
args: &QuotaReconcileArgs,
) -> anyhow::Result<Vec<ReconcileOutcome>> {
use solid_pod_rs::quota::{FsQuotaStore, QuotaPolicy};
let pods: Vec<String> = if args.all {
let mut out = Vec::new();
let mut rd = match tokio::fs::read_dir(&args.root).await {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
anyhow::bail!("storage root does not exist: {}", args.root.display());
}
Err(e) => return Err(e.into()),
};
while let Some(entry) = rd.next_entry().await? {
if entry.file_type().await?.is_dir() {
if let Some(name) = entry.file_name().to_str() {
if !name.starts_with('.') {
out.push(name.to_string());
}
}
}
}
out.sort();
out
} else {
vec![args
.pod_id
.clone()
.expect("clap guarantees pod_id or --all")]
};
let store = FsQuotaStore::new(args.root.clone(), args.default_limit);
let mut outcomes = Vec::with_capacity(pods.len());
for pod in pods {
let usage = store
.reconcile(&pod)
.await
.map_err(|e| anyhow::anyhow!("reconcile {pod}: {e}"))?;
outcomes.push(ReconcileOutcome {
pod,
used_bytes: usage.used_bytes,
limit_bytes: usage.limit_bytes,
});
}
Ok(outcomes)
}
#[cfg(not(feature = "quota"))]
pub async fn run_quota_reconcile(
_args: &QuotaReconcileArgs,
) -> anyhow::Result<Vec<ReconcileOutcome>> {
anyhow::bail!(
"`quota reconcile` requires the `quota` cargo feature. Rebuild with \
`--features solid-pod-rs-server/quota`."
)
}
pub trait Prompt: Send {
fn ask(&mut self, prompt: &str) -> std::io::Result<Option<String>>;
}
pub struct StdinPrompt;
impl Prompt for StdinPrompt {
fn ask(&mut self, prompt: &str) -> std::io::Result<Option<String>> {
use std::io::{BufRead, Write};
let stderr = std::io::stderr();
let mut handle = stderr.lock();
write!(handle, "{prompt}")?;
handle.flush()?;
let stdin = std::io::stdin();
let mut line = String::new();
match stdin.lock().read_line(&mut line) {
Ok(0) => Ok(None),
Ok(_) => Ok(Some(line.trim_end_matches(['\r', '\n']).to_string())),
Err(e) => Err(e),
}
}
}
pub async fn run_account_delete<S, P>(
args: &AccountDeleteArgs,
store: &S,
prompt: &mut P,
) -> anyhow::Result<bool>
where
S: solid_pod_rs_idp::UserStore + ?Sized,
P: Prompt,
{
if !args.yes {
let banner = format!(
"About to delete user {user} and every associated pod + WebID profile.\n\
Type the user id to confirm: ",
user = args.user_id
);
let answer = prompt
.ask(&banner)?
.ok_or_else(|| anyhow::anyhow!("account delete aborted: stdin closed without --yes"))?;
if answer.trim() != args.user_id {
anyhow::bail!(
"account delete aborted: confirmation {answer:?} did not match {user:?}",
user = args.user_id
);
}
}
let deleted = store
.delete(&args.user_id)
.await
.map_err(|e| anyhow::anyhow!("user store delete: {e}"))?;
Ok(deleted)
}
pub async fn run_invite_create<S>(
args: &InviteCreateArgs,
store: &S,
) -> anyhow::Result<(solid_pod_rs_idp::Invite, String)>
where
S: solid_pod_rs_idp::InviteStore + ?Sized,
{
let expires_at = match args.expires_in.as_deref() {
Some(spec) => {
let dur = solid_pod_rs_idp::parse_invite_duration(spec)
.map_err(|e| anyhow::anyhow!("--expires-in {spec:?}: {e}"))?;
let chrono_dur = chrono::Duration::from_std(dur)
.map_err(|e| anyhow::anyhow!("--expires-in {spec:?} out of range: {e}"))?;
Some(chrono::Utc::now() + chrono_dur)
}
None => None,
};
let token = solid_pod_rs_idp::mint_invite_token();
let invite = solid_pod_rs_idp::Invite {
token: token.clone(),
max_uses: args.uses,
expires_at,
};
store
.insert(invite.clone())
.await
.map_err(|e| anyhow::anyhow!("invite store insert: {e}"))?;
let base = args.base_url.trim_end_matches('/');
let url = format!("{base}/invite?token={token}");
Ok((invite, url))
}
pub async fn dispatch(cmd: OperatorCommand) -> anyhow::Result<()> {
match cmd {
OperatorCommand::Quota(QuotaCommand::Reconcile(args)) => {
let outcomes = run_quota_reconcile(&args).await?;
for out in &outcomes {
println!(
"reconciled pod={} used_bytes={} limit_bytes={}",
out.pod, out.used_bytes, out.limit_bytes
);
}
if outcomes.is_empty() {
println!("no pods found under {}", args.root.display());
}
Ok(())
}
OperatorCommand::Account(AccountCommand::Delete(args)) => {
let store = solid_pod_rs_idp::InMemoryUserStore::new();
let mut prompt = StdinPrompt;
let deleted = run_account_delete(&args, &store, &mut prompt).await?;
if deleted {
println!("deleted user {}", args.user_id);
} else {
println!("no such user {}", args.user_id);
}
Ok(())
}
OperatorCommand::Invite(InviteCommand::Create(args)) => {
let store = solid_pod_rs_idp::InMemoryInviteStore::new();
let (invite, url) = run_invite_create(&args, &store).await?;
println!("token: {}", invite.token);
match invite.max_uses {
Some(n) => println!("max_uses: {n}"),
None => println!("max_uses: unlimited"),
}
match invite.expires_at {
Some(t) => println!("expires_at: {}", t.to_rfc3339()),
None => println!("expires_at: never"),
}
println!("url: {url}");
Ok(())
}
}
}