initial commit
This commit is contained in:
parent
64a81a9b13
commit
8715683b4e
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[package]
|
||||
name = "bw-menu"
|
||||
version = "0.1.0"
|
||||
authors = ["Navid Sassan <navid@sassan.ch>"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
env_logger = "0.10"
|
||||
itertools = "0.11"
|
||||
log = "0.4"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
87
src/bitwarden_api/mod.rs
Normal file
87
src/bitwarden_api/mod.rs
Normal file
@ -0,0 +1,87 @@
|
||||
use reqwest;
|
||||
|
||||
pub mod model;
|
||||
|
||||
// https://bitwarden.com/help/vault-management-api/
|
||||
|
||||
pub fn sync() -> Result<model::response::Sync, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.post("http://localhost:8087/sync")
|
||||
.send()?
|
||||
.json::<model::response::Sync>()?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err("success != true in JSON response".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status() -> Result<model::response::Status, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get("http://localhost:8087/status")
|
||||
.send()?
|
||||
.json::<model::response::Status>()?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err("success != true in JSON response".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_object_items() -> Result<model::response::ListObjectItems, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get("http://localhost:8087/list/object/items")
|
||||
// let response = client.get("http://localhost:8087/list/object/items?folderId=50d2f507-8f0a-416c-80a4-afb100a77700")
|
||||
.send()?;
|
||||
|
||||
// dbg!(response.text());
|
||||
// panic!();
|
||||
|
||||
let json = response.json::<model::response::ListObjectItems>()?;
|
||||
if json.success {
|
||||
Ok(json)
|
||||
} else {
|
||||
Err("success != true in JSON response".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_object_collections() -> Result<model::response::ListObjectCollections, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get("http://localhost:8087/list/object/collections")
|
||||
.send()?
|
||||
.json::<model::response::ListObjectCollections>()?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err("success != true in JSON response".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_object_folders() -> Result<model::response::ListObjectFolders, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get("http://localhost:8087/list/object/folders")
|
||||
.send()?
|
||||
.json::<model::response::ListObjectFolders>()?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err("success != true in JSON response".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn object_item(id: &String) -> Result<model::response::ObjectItem, Box<dyn std::error::Error>> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get(format!("http://localhost:8087/object/item/{}", id))
|
||||
.send()?
|
||||
.json::<model::response::ObjectItem>()?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err("success != true in JSON response".into())
|
||||
}
|
||||
}
|
2
src/bitwarden_api/model/mod.rs
Normal file
2
src/bitwarden_api/model/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
#[allow(non_snake_case)]
|
||||
pub mod response;
|
125
src/bitwarden_api/model/response/mod.rs
Normal file
125
src/bitwarden_api/model/response/mod.rs
Normal file
@ -0,0 +1,125 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
// TODO: either use rbw-agent as the backend, or adjust structs to rbw db.rs & api.rs
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Sync {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Status {
|
||||
pub success: bool,
|
||||
pub data: StatusData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StatusData {
|
||||
pub object: String,
|
||||
pub template: StatusDataTemplate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StatusDataTemplate {
|
||||
pub serverUrl: String,
|
||||
pub lastSync: String,
|
||||
pub userEmail: String,
|
||||
pub userId: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectItems {
|
||||
pub success: bool,
|
||||
pub data: ListObjectItemsData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectItemsData {
|
||||
pub object: String,
|
||||
pub data: Vec<ListObjectItemsDataData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectItemsDataData {
|
||||
pub object: String,
|
||||
pub id: String,
|
||||
pub organizationId: Option<String>,
|
||||
pub folderId: Option<String>,
|
||||
#[serde(rename="type")]
|
||||
pub _type: i32,
|
||||
pub reprompt: i32,
|
||||
pub name: String,
|
||||
pub notes: Option<String>,
|
||||
pub favorite: bool,
|
||||
pub login: Option<ListObjectItemsDataLogin>,
|
||||
pub collectionIds: Vec<String>,
|
||||
pub revisionDate: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectItemsDataLogin {
|
||||
pub uris: Option<Vec<ListObjectItemsDataLoginURIs>>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub totp: Option<String>,
|
||||
pub passwordRevisionDate: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectItemsDataLoginURIs {
|
||||
#[serde(rename="match")]
|
||||
pub _match: Option<i32>,
|
||||
pub uri: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectCollections {
|
||||
pub success: bool,
|
||||
pub data: ListObjectCollectionsData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectCollectionsData {
|
||||
pub object: String,
|
||||
pub data: Vec<ListObjectCollectionsDataData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectCollectionsDataData {
|
||||
pub object: String,
|
||||
pub id: String,
|
||||
pub organizationId: String,
|
||||
pub name: String,
|
||||
pub externalId: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectFolders {
|
||||
pub success: bool,
|
||||
pub data: ListObjectFoldersData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectFoldersData {
|
||||
pub object: String,
|
||||
pub data: Vec<ListObjectFoldersDataData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListObjectFoldersDataData {
|
||||
pub object: String,
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ObjectItem {
|
||||
pub success: bool,
|
||||
pub data: ListObjectItemsDataData,
|
||||
}
|
||||
|
290
src/main.rs
Normal file
290
src/main.rs
Normal file
@ -0,0 +1,290 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use itertools::Itertools;
|
||||
use log::{debug, error, info};
|
||||
use std::env;
|
||||
use std::io::Write;
|
||||
use std::process::Stdio;
|
||||
use std::{process, collections::HashMap};
|
||||
|
||||
use crate::bitwarden_api::model;
|
||||
|
||||
mod bitwarden_api;
|
||||
|
||||
// GOALS:
|
||||
// * `search` item via dmenu / rofi / cli
|
||||
// * `search --mark` parameter that remembers the marked entry
|
||||
// * `mark get {field}` returns the field of the marked element (id, title, folder, username, password, etc)
|
||||
// * "scroll" through marked history, mark as active again
|
||||
// * automatically execute `export BW_SESSION="$(bw unlock --raw)" && bw serve --hostname 127.0.0.1&` if required
|
||||
// * unlock with system keyring if possible
|
||||
// * autotype?
|
||||
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)] // Read from `Cargo.toml`
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: CliCommands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CliCommands {
|
||||
Select {
|
||||
// Show the details of the item
|
||||
title: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match &cli.command {
|
||||
CliCommands::Select{title: _} => {
|
||||
// the title is always ignored, we want the id which we get via the environment variables
|
||||
debug!("Running in select mode");
|
||||
rofi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// the flow is:
|
||||
// 1. bw-menu starts rofi in the script mode
|
||||
// 2. rofi calls bw-menu calls bw-menu to get a list of items to display
|
||||
// 3. the user interacts with the list
|
||||
// 4. rofi sets environment variables, and calls bw-menu with the title of the selected item
|
||||
// 5. bw-menu acts based on the environment variables
|
||||
fn rofi() {
|
||||
let rofi_retv = env::var("ROFI_RETV");
|
||||
let rofi_info = env::var("ROFI_INFO");
|
||||
dbg!(&rofi_retv);
|
||||
dbg!(&rofi_info);
|
||||
|
||||
match rofi_retv {
|
||||
Err(_) => {
|
||||
// this means rofi is not running yet, so let's start it
|
||||
debug!("starting initial rofi process");
|
||||
let _ = process::Command::new("rofi")
|
||||
.arg("-show")
|
||||
.arg("bw")
|
||||
.arg("-modes")
|
||||
.arg("bw:./target/debug/bw-menu select") // todo: set the path according to argv[0]
|
||||
// we need to remap some default so that we can use the keys for custom binds
|
||||
.arg("-kb-accept-alt")
|
||||
.arg("F12")
|
||||
.arg("-kb-accept-custom")
|
||||
.arg("F11")
|
||||
// set our custom keybinds
|
||||
.arg("-kb-custom-1")
|
||||
.arg("Shift+Return")
|
||||
.arg("-kb-custom-2")
|
||||
.arg("Control+Return")
|
||||
.spawn()
|
||||
.expect("Failed to spawn child process"); // TODO: better error handling
|
||||
}
|
||||
Ok(_) => {
|
||||
// this means we are being called by rofi as a custom script
|
||||
// either generate the list, or act on input
|
||||
let rofi_retv = rofi_retv.unwrap_or_default().to_string().parse::<i32>().unwrap_or(0);
|
||||
let rofi_info = rofi_info.unwrap_or_default();
|
||||
|
||||
if rofi_retv == 0 {
|
||||
// 0: Initial call of script
|
||||
generate_rofi_list(get_bitwarden_logins());
|
||||
} else {
|
||||
let item = match crate::bitwarden_api::object_item(&rofi_info) {
|
||||
Ok(response) => response,
|
||||
Err(msg) => {
|
||||
error!("Failed to get Bitwarden item with id {}:\n{}", rofi_info, msg);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
// 1: Selected an entry
|
||||
if rofi_retv == 1 {
|
||||
info!("copy password");
|
||||
if let Some(login) = item.data.login {
|
||||
if let Some(password) = login.password {
|
||||
save_to_clipboard(password);
|
||||
}
|
||||
}
|
||||
// 10-28: Custom keybinding 1-19
|
||||
} else if rofi_retv == 10 {
|
||||
info!("copy username");
|
||||
if let Some(login) = item.data.login {
|
||||
if let Some(username) = login.username {
|
||||
save_to_clipboard(username);
|
||||
}
|
||||
}
|
||||
} else if rofi_retv == 11 {
|
||||
info!("show item");
|
||||
println!("\0prompt\x1fSelect field");
|
||||
println!("\0message\x1fReturn | Copy field");
|
||||
println!("{}\0icon\x1f{}\x1finfo\x1f{}", "TODO", "folder", "single-item:todo");
|
||||
// TODO: not sure how to proceed here. currently pressing return leads to the "copy password" action
|
||||
// one option is to pass the action in the info and parsing that
|
||||
|
||||
// if let Some(login) = item.data.login {
|
||||
// if let Some(username) = login.username {
|
||||
// println!("Username: {}", username);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// we are done here
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_bitwarden_logins() -> HashMap<String, model::response::ListObjectItemsDataData> {
|
||||
match crate::bitwarden_api::status() {
|
||||
Ok(response) => {
|
||||
if response.data.template.status != "unlocked" {
|
||||
error!("Vault is {}, should be unlocked!", response.data.template.status);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to get Bitwarden status:\n{msg}");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
match crate::bitwarden_api::sync() {
|
||||
Ok(_) => info!("Sync'd Bitwarden Vault."),
|
||||
Err(msg) => {
|
||||
error!("Failed to sync Bitwarden Vault:\n{msg}");
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
let items = match crate::bitwarden_api::list_object_items() {
|
||||
Ok(response) => {
|
||||
info!("Got list of Bitwarden items.");
|
||||
response.data.data
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to get list of Bitwarden items:\n{msg}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let collections: HashMap<String, String> = match crate::bitwarden_api::list_object_collections() {
|
||||
Ok(response) => {
|
||||
info!("Got list of Bitwarden collections.");
|
||||
response.data.data.iter()
|
||||
.map(|item| (item.id.clone(), item.name.clone()))
|
||||
.collect()
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to get list of Bitwarden collections:\n{msg}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let folders: HashMap<String, String> = match crate::bitwarden_api::list_object_folders() {
|
||||
Ok(response) => {
|
||||
info!("Got list of Bitwarden folders.");
|
||||
response.data.data.iter()
|
||||
.filter(|item| item.id.is_some())
|
||||
.map(|item| (item.id.as_ref().unwrap().clone(), item.name.clone()))
|
||||
.collect()
|
||||
}
|
||||
Err(msg) => {
|
||||
error!("Failed to get list of Bitwarden folders:\n{msg}");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let mut logins: HashMap<String, model::response::ListObjectItemsDataData> = HashMap::new();
|
||||
for item in items {
|
||||
if item.login.is_some() {
|
||||
let title = get_title(&item, &folders, &collections);
|
||||
debug!("Login: {}", title);
|
||||
logins.insert(title, item);
|
||||
}
|
||||
// break;
|
||||
}
|
||||
// dbg!(&logins);
|
||||
logins
|
||||
}
|
||||
|
||||
|
||||
fn generate_rofi_list(logins: HashMap<String, model::response::ListObjectItemsDataData>) {
|
||||
println!("\0prompt\x1fSelect item");
|
||||
println!("\0message\x1fReturn | Copy password\rShift+Return | Copy username or selected field\rControl+Return | Show item");
|
||||
println!("\0use-hot-keys\x1ftrue");
|
||||
println!("\0no-custom\x1ftrue");
|
||||
println!("\0markup-rows\x1ftrue");
|
||||
|
||||
for title in logins.keys().sorted() {
|
||||
print!("{title}");
|
||||
let icon = "folder"; // TODO: what icons are available? ~/.icons seems to work
|
||||
println!("\0icon\x1f{}\x1finfo\x1f{}", icon, logins[title].id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn get_title(item: &model::response::ListObjectItemsDataData, folders: &HashMap<String, String>, collections: &HashMap<String, String>) -> String {
|
||||
let mut title = String::new();
|
||||
let login = item.login.as_ref().unwrap();
|
||||
|
||||
if item.folderId.is_some() {
|
||||
title.push_str(&format!("{}/", folders[item.folderId.as_ref().unwrap()]));
|
||||
}
|
||||
|
||||
// TODO: how do we handle multiple collections nicely? i dont like the pipe for searching. my
|
||||
// fav solution right now is to display the entry multiple times, eg "000/000-p-xca01", "001/000-p-xca01"
|
||||
for collection_id in &item.collectionIds {
|
||||
title.push_str(&collections[collection_id]);
|
||||
}
|
||||
if !item.collectionIds.is_empty() {
|
||||
title.push_str("/");
|
||||
}
|
||||
|
||||
if title.len() == 0 {
|
||||
title.push_str("ungrouped/");
|
||||
}
|
||||
|
||||
title.push_str(&item.name);
|
||||
|
||||
if login.username.is_some() {
|
||||
title.push_str(&format!(" ({})", login.username.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
title
|
||||
}
|
||||
|
||||
|
||||
fn save_to_clipboard(input: String) {
|
||||
// using xclip blocks the app. maybe piping the output to /dev/null would fix this
|
||||
// let mut cmd = process::Command::new("xclip")
|
||||
// .arg("-in")
|
||||
// .arg("-selection")
|
||||
// .arg("clipboard")
|
||||
// .stdin(Stdio::piped())
|
||||
// .spawn()
|
||||
// .expect("Failed to spawn child process");
|
||||
|
||||
let mut cmd = process::Command::new("xsel")
|
||||
.arg("--input")
|
||||
.arg("--clipboard")
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to spawn child process"); // TODO: better error handling
|
||||
|
||||
let mut stdin = cmd.stdin.take().expect("Failed to open stdin.");
|
||||
|
||||
let child_thread = std::thread::spawn(move || {
|
||||
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
|
||||
});
|
||||
|
||||
let result = child_thread.join();
|
||||
|
||||
match result {
|
||||
Ok(_) => debug!("Child thread completed successfully"),
|
||||
Err(e) => error!("Child thread encountered an error: {:?}", e),
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user