use crate::{cli, db, frontmatter, helpers};
use rand::RngCore;
pub fn resolve_token(explicit: Option<String>) -> String {
match explicit {
Some(t) => t,
None => match std::env::var("TWOFOLD_TOKEN") {
Ok(t) => t,
Err(_) => {
eprintln!(
"Error: --token not provided and TWOFOLD_TOKEN is not set.\n\
Provide a token via --token <TOKEN> or set TWOFOLD_TOKEN."
);
std::process::exit(1);
}
},
}
}
pub fn resolve_db_path(explicit: Option<String>) -> String {
explicit
.or_else(|| std::env::var("TWOFOLD_DB_PATH").ok())
.unwrap_or_else(|| "./twofold.db".to_string())
}
pub fn make_blocking_client() -> reqwest::blocking::Client {
match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => {
eprintln!("Failed to create HTTP client: {e}");
std::process::exit(1);
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}…", &s[..max - 1])
}
}
pub fn run_publish(args: cli::PublishArgs) {
let token = resolve_token(args.token);
let content = read_publish_source(&args.path);
let body = frontmatter::apply_frontmatter(
&content,
frontmatter::FrontmatterFields {
title: args.title,
slug: args.slug,
theme: args.theme,
expiry: args.expiry,
password: args.password,
description: None,
},
);
let url = format!("{}/api/v1/documents", args.server.trim_end_matches('/'));
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => {
eprintln!("Failed to create HTTP client: {e}");
std::process::exit(1);
}
};
let response = match client
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "text/markdown")
.body(body)
.send()
{
Ok(r) => r,
Err(e) => {
eprintln!("Request failed: {e}");
std::process::exit(1);
}
};
let status = response.status();
if status == reqwest::StatusCode::CREATED {
let body: serde_json::Value = match response.json() {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse server response: {e}");
std::process::exit(1);
}
};
if let Some(doc_url) = body.get("url").and_then(|v| v.as_str()) {
println!("{doc_url}");
} else {
eprintln!("Server returned 201 but no `url` field in response.");
std::process::exit(1);
}
} else {
let body_text = response.text().unwrap_or_default();
eprintln!("Publish failed: HTTP {status}\n{body_text}");
std::process::exit(1);
}
}
fn read_publish_source(path: &str) -> String {
if path == "-" {
use std::io::Read;
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("Failed to read from stdin: {e}");
std::process::exit(1);
}
buf
} else {
match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to read file '{path}': {e}");
std::process::exit(1);
}
}
}
}
pub fn run_list(args: cli::ListArgs) {
let token = resolve_token(args.token);
let url = format!(
"{}/api/v1/documents?limit={}",
args.server.trim_end_matches('/'),
args.limit
);
let client = make_blocking_client();
let response = match client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
{
Ok(r) => r,
Err(e) => {
eprintln!("Request failed: {e}");
std::process::exit(1);
}
};
let status = response.status();
if !status.is_success() {
let body = response.text().unwrap_or_default();
eprintln!("List failed: HTTP {status}\n{body}");
std::process::exit(1);
}
let body: serde_json::Value = match response.json() {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse server response: {e}");
std::process::exit(1);
}
};
let docs = body.get("documents").and_then(|v| v.as_array());
let docs = match docs {
Some(d) => d,
None => {
eprintln!("Unexpected response format");
std::process::exit(1);
}
};
println!("{:<24} {:<32} {:<21} EXPIRES", "SLUG", "TITLE", "CREATED");
println!("{}", "-".repeat(90));
for doc in docs {
let slug = doc.get("slug").and_then(|v| v.as_str()).unwrap_or("-");
let title = doc.get("title").and_then(|v| v.as_str()).unwrap_or("-");
let created = doc
.get("created_at")
.and_then(|v| v.as_str())
.unwrap_or("-");
let expires = doc
.get("expires_at")
.and_then(|v| v.as_str())
.unwrap_or("never");
let slug_d = truncate(slug, 23);
let title_d = truncate(title, 31);
let created_d = &created[..std::cmp::min(16, created.len())];
let expires_d = if expires == "never" {
"never".to_string()
} else {
expires[..std::cmp::min(16, expires.len())].to_string()
};
println!(
"{:<24} {:<32} {:<21} {}",
slug_d, title_d, created_d, expires_d
);
}
}
pub fn run_delete(args: cli::DeleteArgs) {
let token = resolve_token(args.token);
let url = format!(
"{}/api/v1/documents/{}",
args.server.trim_end_matches('/'),
args.slug
);
let client = make_blocking_client();
let response = match client
.delete(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
{
Ok(r) => r,
Err(e) => {
eprintln!("Request failed: {e}");
std::process::exit(1);
}
};
let status = response.status();
match status.as_u16() {
204 => println!("Deleted: {}", args.slug),
401 => {
eprintln!("Auth error: check your token");
std::process::exit(1);
}
404 => {
eprintln!("Error: document '{}' not found", args.slug);
std::process::exit(1);
}
_ => {
let body = response.text().unwrap_or_default();
eprintln!("Delete failed: HTTP {status}\n{body}");
std::process::exit(1);
}
}
}
pub fn run_audit(args: cli::AuditArgs) {
let token = resolve_token(args.token);
let url = format!(
"{}/api/v1/audit?limit={}",
args.server.trim_end_matches('/'),
args.limit
);
let client = make_blocking_client();
let response = match client
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
{
Ok(r) => r,
Err(e) => {
eprintln!("Request failed: {e}");
std::process::exit(1);
}
};
let status = response.status();
if !status.is_success() {
let body = response.text().unwrap_or_default();
eprintln!("Audit failed: HTTP {status}\n{body}");
std::process::exit(1);
}
let body: serde_json::Value = match response.json() {
Ok(v) => v,
Err(e) => {
eprintln!("Failed to parse server response: {e}");
std::process::exit(1);
}
};
let entries = body.get("entries").and_then(|v| v.as_array());
let entries = match entries {
Some(e) => e,
None => {
eprintln!("Unexpected response format");
std::process::exit(1);
}
};
println!("{:<21} {:<9} {:<25} TOKEN", "TIMESTAMP", "ACTION", "SLUG");
println!("{}", "-".repeat(75));
for entry in entries {
let timestamp = entry
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("-");
let action = entry.get("action").and_then(|v| v.as_str()).unwrap_or("-");
let slug = entry.get("slug").and_then(|v| v.as_str()).unwrap_or("-");
let token_name = entry
.get("token_name")
.and_then(|v| v.as_str())
.unwrap_or("-");
let ts_d = ×tamp[..std::cmp::min(20, timestamp.len())];
let slug_d = truncate(slug, 24);
println!("{:<21} {:<9} {:<25} {}", ts_d, action, slug_d, token_name);
}
}
pub fn run_token(args: cli::TokenArgs) {
match args.action {
cli::TokenAction::Create { name, db } => token_create(&name, &resolve_db_path(db)),
cli::TokenAction::List { db } => token_list(&resolve_db_path(db)),
cli::TokenAction::Revoke { name, db } => token_revoke(&name, &resolve_db_path(db)),
}
}
fn token_create(name: &str, db_path: &str) {
let database = match db::Db::open(db_path) {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to open database '{db_path}': {e}");
std::process::exit(1);
}
};
match database.token_name_exists(name) {
Ok(true) => {
eprintln!("Error: Token name '{name}' already exists.");
std::process::exit(1);
}
Err(e) => {
eprintln!("Database error: {e}");
std::process::exit(1);
}
_ => {}
}
use base64::Engine;
use rand::RngCore;
let now = helpers::chrono_now();
let token_plain = 'generate: {
for attempt in 0..3u8 {
let mut token_bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut token_bytes);
let plain = format!(
"tf_{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token_bytes)
);
let hash = match helpers::hash_password(&plain) {
Ok(h) => h,
Err(_) => {
eprintln!("Failed to hash token");
std::process::exit(1);
}
};
let id = nanoid::nanoid!(10);
let prefix = plain.chars().take(8).collect::<String>();
let record = db::TokenRecord {
id,
name: name.to_string(),
hash,
created_at: now.clone(),
last_used: None,
revoked: false,
prefix: Some(prefix),
};
match database.insert_token(&record) {
Ok(()) => break 'generate plain,
Err(e)
if e.to_string()
.contains("UNIQUE constraint failed: tokens.prefix") =>
{
if attempt < 2 {
eprintln!(
"Warning: prefix collision on attempt {}; regenerating.",
attempt + 1
);
continue;
}
eprintln!("Failed to store token after 3 attempts (prefix collision): {e}");
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to store token: {e}");
std::process::exit(1);
}
}
}
eprintln!("Failed to generate a unique token prefix.");
std::process::exit(1);
};
println!("{token_plain}");
}
fn token_list(db_path: &str) {
let database = match db::Db::open(db_path) {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to open database '{db_path}': {e}");
std::process::exit(1);
}
};
let tokens = match database.list_tokens() {
Ok(t) => t,
Err(e) => {
eprintln!("Failed to list tokens: {e}");
std::process::exit(1);
}
};
println!(
"{:<20} {:<22} {:<22} STATUS",
"NAME", "CREATED", "LAST USED"
);
for token in tokens {
let status = if token.revoked { "revoked" } else { "active" };
let last_used = token.last_used.as_deref().unwrap_or("never");
let created = &token.created_at[..std::cmp::min(16, token.created_at.len())];
let used = if last_used == "never" {
"never".to_string()
} else {
last_used[..std::cmp::min(16, last_used.len())].to_string()
};
println!("{:<20} {:<22} {:<22} {}", token.name, created, used, status);
}
}
fn token_revoke(name: &str, db_path: &str) {
let database = match db::Db::open(db_path) {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to open database '{db_path}': {e}");
std::process::exit(1);
}
};
match database.revoke_token(name) {
Ok(true) => println!("Token '{name}' revoked."),
Ok(false) => {
eprintln!("Error: Token '{name}' not found or already revoked.");
std::process::exit(1);
}
Err(e) => {
eprintln!("Database error: {e}");
std::process::exit(1);
}
}
}
pub fn run_client(args: cli::ClientArgs) {
match args.action {
cli::ClientAction::Create {
name,
redirect_uri,
db,
} => client_create(&name, &redirect_uri, &resolve_db_path(db)),
cli::ClientAction::List { db } => client_list(&resolve_db_path(db)),
cli::ClientAction::Revoke { client_id, db } => {
client_revoke(&client_id, &resolve_db_path(db))
}
}
}
fn client_create(name: &str, redirect_uri: &str, db_path: &str) {
let database = match db::Db::open(db_path) {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to open database '{db_path}': {e}");
std::process::exit(1);
}
};
let client_id = {
let mut bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
};
let client_secret = {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()
};
let now = helpers::chrono_now();
let row = db::OAuthClientRow {
client_id: client_id.clone(),
client_name: name.to_string(),
redirect_uris: serde_json::json!([redirect_uri]).to_string(),
grant_types: serde_json::json!(["authorization_code"]).to_string(),
response_types: serde_json::json!(["code"]).to_string(),
token_endpoint_auth_method: "client_secret_post".to_string(),
created_at: now,
provisioned: true,
client_secret: Some(client_secret.clone()),
};
match database.insert_oauth_client(&row) {
Ok(()) => {
println!("client_id: {client_id}");
println!("client_secret: {client_secret}");
println!();
println!("Store the client_secret now — it will not be shown again.");
}
Err(e) => {
eprintln!("Failed to create client: {e}");
std::process::exit(1);
}
}
}
fn client_list(db_path: &str) {
let database = match db::Db::open(db_path) {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to open database '{db_path}': {e}");
std::process::exit(1);
}
};
let clients = match database.list_provisioned_clients() {
Ok(c) => c,
Err(e) => {
eprintln!("Failed to list clients: {e}");
std::process::exit(1);
}
};
if clients.is_empty() {
println!("No provisioned clients.");
return;
}
println!("{:<38} {:<24} {:<21}", "CLIENT_ID", "NAME", "CREATED");
println!("{}", "-".repeat(85));
for client in clients {
let created = &client.created_at[..std::cmp::min(16, client.created_at.len())];
println!(
"{:<38} {:<24} {}",
client.client_id, client.client_name, created
);
}
}
fn client_revoke(client_id: &str, db_path: &str) {
let database = match db::Db::open(db_path) {
Ok(d) => d,
Err(e) => {
eprintln!("Failed to open database '{db_path}': {e}");
std::process::exit(1);
}
};
match database.revoke_provisioned_client(client_id) {
Ok(true) => println!("Client '{client_id}' revoked."),
Ok(false) => {
eprintln!("Error: Client '{client_id}' not found.");
std::process::exit(1);
}
Err(e) => {
eprintln!("Database error: {e}");
std::process::exit(1);
}
}
}