Compare commits
	
		
			No commits in common. "python" and "main" have entirely different histories.
		
	
	
		
	
		
							
								
								
									
										16
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,16 @@
 | 
			
		||||
# ---> Rust
 | 
			
		||||
# Generated by Cargo
 | 
			
		||||
# will have compiled files and executables
 | 
			
		||||
debug/
 | 
			
		||||
target/
 | 
			
		||||
 | 
			
		||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
 | 
			
		||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
 | 
			
		||||
Cargo.lock
 | 
			
		||||
 | 
			
		||||
# These are backup files generated by rustfmt
 | 
			
		||||
**/*.rs.bk
 | 
			
		||||
 | 
			
		||||
# MSVC Windows builds of rustc generate these, which store debugging information
 | 
			
		||||
*.pdb
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Cargo.toml
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 | 
			
		||||
 | 
			
		||||
[package]
 | 
			
		||||
name = "bw-menu"
 | 
			
		||||
version = "0.1.0-next"
 | 
			
		||||
authors = ["Navid Sassan <navid@sassan.ch>"]
 | 
			
		||||
edition = "2021"
 | 
			
		||||
license = "The Unlicense"
 | 
			
		||||
 | 
			
		||||
[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"] }
 | 
			
		||||
serde_json = "1.0"
 | 
			
		||||
							
								
								
									
										24
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								LICENSE
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
			
		||||
This is free and unencumbered software released into the public domain.
 | 
			
		||||
 | 
			
		||||
Anyone is free to copy, modify, publish, use, compile, sell, or
 | 
			
		||||
distribute this software, either in source code form or as a compiled
 | 
			
		||||
binary, for any purpose, commercial or non-commercial, and by any
 | 
			
		||||
means.
 | 
			
		||||
 | 
			
		||||
In jurisdictions that recognize copyright laws, the author or authors
 | 
			
		||||
of this software dedicate any and all copyright interest in the
 | 
			
		||||
software to the public domain. We make this dedication for the benefit
 | 
			
		||||
of the public at large and to the detriment of our heirs and
 | 
			
		||||
successors. We intend this dedication to be an overt act of
 | 
			
		||||
relinquishment in perpetuity of all present and future rights to this
 | 
			
		||||
software under copyright law.
 | 
			
		||||
 | 
			
		||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 | 
			
		||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
 | 
			
		||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
 | 
			
		||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
 | 
			
		||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
 | 
			
		||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 | 
			
		||||
OTHER DEALINGS IN THE SOFTWARE.
 | 
			
		||||
 | 
			
		||||
For more information, please refer to <http://unlicense.org/>
 | 
			
		||||
							
								
								
									
										38
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										38
									
								
								README.md
									
									
									
									
									
								
							@ -1,17 +1,26 @@
 | 
			
		||||
# Bitwarden Menu
 | 
			
		||||
# bw-menu
 | 
			
		||||
 | 
			
		||||
Bitwarden Menu
 | 
			
		||||
 | 
			
		||||
## Installation
 | 
			
		||||
```bash
 | 
			
		||||
# install an up-to-date rust and cargo, see https://rustup.rs/
 | 
			
		||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
 | 
			
		||||
 | 
			
		||||
# install dependencies
 | 
			
		||||
sudo dnf install -y python3 python3-pyperclip xclip rofi
 | 
			
		||||
sudo dnf install rofi
 | 
			
		||||
 | 
			
		||||
git clone --branch python https://git.navidsassan.ch/navid.sassan/bw-menu.git
 | 
			
		||||
git clone https://github.com/Linuxfabrik/lib.git
 | 
			
		||||
mkdir -p ~/.cargo
 | 
			
		||||
cat > ~/.cargo/config.toml << 'EOF'
 | 
			
		||||
[registries.gitea]
 | 
			
		||||
index = "https://git.navidsassan.ch/navid.sassan/_cargo-index.git"
 | 
			
		||||
 | 
			
		||||
cd bw-menu
 | 
			
		||||
ln -s ../../lib src/lib
 | 
			
		||||
./src/main.py --help
 | 
			
		||||
[net]
 | 
			
		||||
git-fetch-with-cli = true
 | 
			
		||||
EOF
 | 
			
		||||
cargo install bw-menu --registry gitea
 | 
			
		||||
 | 
			
		||||
~/.cargo/bin/bw-menu --version
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -20,11 +29,18 @@ ln -s ../../lib src/lib
 | 
			
		||||
# start bw serve
 | 
			
		||||
export BW_SESSION="$(bw unlock --raw)" && bw serve --hostname 127.0.0.1&
 | 
			
		||||
# get dropdown (takes a second)
 | 
			
		||||
./src/main.py select
 | 
			
		||||
bw-menu select
 | 
			
		||||
 | 
			
		||||
# get password of last selected entry
 | 
			
		||||
./src/main.py history get 0 password
 | 
			
		||||
bw-menu history get 0 password
 | 
			
		||||
 | 
			
		||||
# list 5 last selected entries
 | 
			
		||||
./src/main.py history list
 | 
			
		||||
# list 5 last selected entries (currently saved in `/tmp`)
 | 
			
		||||
bw-menu history list
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Known Issues
 | 
			
		||||
 | 
			
		||||
* The collections of entries that are in multiple collections are not displayed correctly
 | 
			
		||||
* The history is reset after every reboot if `/tmp` is not persistent
 | 
			
		||||
 | 
			
		||||
A re-write in Python is planned, during which these issues will be fixed.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										443
									
								
								src/bitwarden.py
									
									
									
									
									
								
							
							
						
						
									
										443
									
								
								src/bitwarden.py
									
									
									
									
									
								
							@ -1,443 +0,0 @@
 | 
			
		||||
#!/usr/bin/python
 | 
			
		||||
# -*- coding: utf-8 -*-
 | 
			
		||||
 | 
			
		||||
# Copyright: (c) 2022, Linuxfabrik, Zurich, Switzerland, https://www.linuxfabrik.ch
 | 
			
		||||
# The Unlicense (see LICENSE or https://unlicense.org/)
 | 
			
		||||
 | 
			
		||||
from __future__ import absolute_import, division, print_function
 | 
			
		||||
 | 
			
		||||
import email.encoders
 | 
			
		||||
import email.mime.application
 | 
			
		||||
import email.mime.multipart
 | 
			
		||||
import email.mime.nonmultipart
 | 
			
		||||
import email.parser
 | 
			
		||||
import email.utils
 | 
			
		||||
import json
 | 
			
		||||
import mimetypes
 | 
			
		||||
import os
 | 
			
		||||
import urllib.parse
 | 
			
		||||
from urllib.error import HTTPError, URLError
 | 
			
		||||
 | 
			
		||||
from ansible.module_utils.common.collections import Mapping
 | 
			
		||||
from ansible.module_utils.six import PY2, PY3, string_types
 | 
			
		||||
from ansible.module_utils.six.moves import cStringIO
 | 
			
		||||
 | 
			
		||||
try:
 | 
			
		||||
    import email.policy
 | 
			
		||||
except ImportError:
 | 
			
		||||
    # Py2
 | 
			
		||||
    import email.generator
 | 
			
		||||
 | 
			
		||||
from ansible.module_utils._text import to_bytes, to_native, to_text
 | 
			
		||||
from ansible.module_utils.urls import (ConnectionError, SSLValidationError,
 | 
			
		||||
                                       open_url)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def prepare_multipart_no_base64(fields):
 | 
			
		||||
    """Taken from ansible.module_utils.urls, but adjusted to not no encoding as the bitwarden API does not work with that
 | 
			
		||||
    (even though it should according to the RFC, Content-Transfer-Encoding is deprecated but not removed).
 | 
			
		||||
    See https://github.com/ansible/ansible/issues/73621
 | 
			
		||||
 | 
			
		||||
    Takes a mapping, and prepares a multipart/form-data body
 | 
			
		||||
 | 
			
		||||
    :arg fields: Mapping
 | 
			
		||||
    :returns: tuple of (content_type, body) where ``content_type`` is
 | 
			
		||||
        the ``multipart/form-data`` ``Content-Type`` header including
 | 
			
		||||
        ``boundary`` and ``body`` is the prepared bytestring body
 | 
			
		||||
 | 
			
		||||
    Payload content from a file will be base64 encoded and will include
 | 
			
		||||
    the appropriate ``Content-Transfer-Encoding`` and ``Content-Type``
 | 
			
		||||
    headers.
 | 
			
		||||
 | 
			
		||||
    Example:
 | 
			
		||||
        {
 | 
			
		||||
            "file1": {
 | 
			
		||||
                "filename": "/bin/true",
 | 
			
		||||
                "mime_type": "application/octet-stream"
 | 
			
		||||
            },
 | 
			
		||||
            "file2": {
 | 
			
		||||
                "content": "text based file content",
 | 
			
		||||
                "filename": "fake.txt",
 | 
			
		||||
                "mime_type": "text/plain",
 | 
			
		||||
            },
 | 
			
		||||
            "text_form_field": "value"
 | 
			
		||||
        }
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    if not isinstance(fields, Mapping):
 | 
			
		||||
        raise TypeError(
 | 
			
		||||
            'Mapping is required, cannot be type %s' % fields.__class__.__name__
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    m = email.mime.multipart.MIMEMultipart('form-data')
 | 
			
		||||
    for field, value in sorted(fields.items()):
 | 
			
		||||
        if isinstance(value, string_types):
 | 
			
		||||
            main_type = 'text'
 | 
			
		||||
            sub_type = 'plain'
 | 
			
		||||
            content = value
 | 
			
		||||
            filename = None
 | 
			
		||||
        elif isinstance(value, Mapping):
 | 
			
		||||
            filename = value.get('filename')
 | 
			
		||||
            content = value.get('content')
 | 
			
		||||
            if not any((filename, content)):
 | 
			
		||||
                raise ValueError('at least one of filename or content must be provided')
 | 
			
		||||
 | 
			
		||||
            mime = value.get('mime_type')
 | 
			
		||||
            if not mime:
 | 
			
		||||
                try:
 | 
			
		||||
                    mime = mimetypes.guess_type(filename or '', strict=False)[0] or 'application/octet-stream'
 | 
			
		||||
                except Exception:
 | 
			
		||||
                    mime = 'application/octet-stream'
 | 
			
		||||
            main_type, sep, sub_type = mime.partition('/')
 | 
			
		||||
        else:
 | 
			
		||||
            raise TypeError(
 | 
			
		||||
                'value must be a string, or mapping, cannot be type %s' % value.__class__.__name__
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        if not content and filename:
 | 
			
		||||
            with open(to_bytes(filename, errors='surrogate_or_strict'), 'rb') as f:
 | 
			
		||||
                part = email.mime.application.MIMEApplication(f.read(), _encoder=email.encoders.encode_noop)
 | 
			
		||||
                del part['Content-Type']
 | 
			
		||||
                part.add_header('Content-Type', '%s/%s' % (main_type, sub_type))
 | 
			
		||||
        else:
 | 
			
		||||
            part = email.mime.nonmultipart.MIMENonMultipart(main_type, sub_type)
 | 
			
		||||
            part.set_payload(to_bytes(content))
 | 
			
		||||
 | 
			
		||||
        part.add_header('Content-Disposition', 'form-data')
 | 
			
		||||
        del part['MIME-Version']
 | 
			
		||||
        part.set_param(
 | 
			
		||||
            'name',
 | 
			
		||||
            field,
 | 
			
		||||
            header='Content-Disposition'
 | 
			
		||||
        )
 | 
			
		||||
        if filename:
 | 
			
		||||
            part.set_param(
 | 
			
		||||
                'filename',
 | 
			
		||||
                to_native(os.path.basename(filename)),
 | 
			
		||||
                header='Content-Disposition'
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        m.attach(part)
 | 
			
		||||
 | 
			
		||||
    if PY3:
 | 
			
		||||
        # Ensure headers are not split over multiple lines
 | 
			
		||||
        # The HTTP policy also uses CRLF by default
 | 
			
		||||
        b_data = m.as_bytes(policy=email.policy.HTTP)
 | 
			
		||||
    else:
 | 
			
		||||
        # Py2
 | 
			
		||||
        # We cannot just call ``as_string`` since it provides no way
 | 
			
		||||
        # to specify ``maxheaderlen``
 | 
			
		||||
        fp = cStringIO()  # cStringIO seems to be required here
 | 
			
		||||
        # Ensure headers are not split over multiple lines
 | 
			
		||||
        g = email.generator.Generator(fp, maxheaderlen=0)
 | 
			
		||||
        g.flatten(m)
 | 
			
		||||
        # ``fix_eols`` switches from ``\n`` to ``\r\n``
 | 
			
		||||
        b_data = email.utils.fix_eols(fp.getvalue())
 | 
			
		||||
    del m
 | 
			
		||||
 | 
			
		||||
    headers, sep, b_content = b_data.partition(b'\r\n\r\n')
 | 
			
		||||
    del b_data
 | 
			
		||||
 | 
			
		||||
    if PY3:
 | 
			
		||||
        parser = email.parser.BytesHeaderParser().parsebytes
 | 
			
		||||
    else:
 | 
			
		||||
        # Py2
 | 
			
		||||
        parser = email.parser.HeaderParser().parsestr
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
        parser(headers)['content-type'],  # Message converts to native strings
 | 
			
		||||
        b_content
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BitwardenException(Exception):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Bitwarden(object):
 | 
			
		||||
    # https://bitwarden.com/help/vault-management-api
 | 
			
		||||
 | 
			
		||||
    def __init__(self, hostname='127.0.0.1', port=8087):
 | 
			
		||||
        self._base_url = 'http://%s:%s' % (hostname, port)
 | 
			
		||||
 | 
			
		||||
    def _api_call(self, url_path, method='GET', body=None, body_format='json'):
 | 
			
		||||
        url = '%s/%s' % (self._base_url, url_path)
 | 
			
		||||
 | 
			
		||||
        headers = {}
 | 
			
		||||
        if body:
 | 
			
		||||
            if body_format == 'json':
 | 
			
		||||
                body = json.dumps(body)
 | 
			
		||||
                headers['Content-Type'] = 'application/json'
 | 
			
		||||
            elif body_format == 'form-multipart':
 | 
			
		||||
                try:
 | 
			
		||||
                    content_type, body = prepare_multipart_no_base64(body)
 | 
			
		||||
                except (TypeError, ValueError) as e:
 | 
			
		||||
                    BitwardenException('failed to parse body as form-multipart: %s' % to_native(e))
 | 
			
		||||
                headers['Content-Type'] = content_type
 | 
			
		||||
 | 
			
		||||
        # mostly taken from ansible.builtin.url lookup plugin
 | 
			
		||||
        try:
 | 
			
		||||
            response = open_url(url, method=method, data=body, headers=headers)
 | 
			
		||||
        except HTTPError as e:
 | 
			
		||||
            raise BitwardenException("Received HTTP error for %s : %s" % (url, to_native(e)))
 | 
			
		||||
        except URLError as e:
 | 
			
		||||
            raise BitwardenException("Failed lookup url for %s : %s" % (url, to_native(e)))
 | 
			
		||||
        except SSLValidationError as e:
 | 
			
		||||
            raise BitwardenException("Error validating the server's certificate for %s: %s" % (url, to_native(e)))
 | 
			
		||||
        except ConnectionError as e:
 | 
			
		||||
            raise BitwardenException("Error connecting to %s: %s" % (url, to_native(e)))
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            result = json.loads(to_text(response.read()))
 | 
			
		||||
        except json.decoder.JSONDecodeError as e:
 | 
			
		||||
            raise BitwardenException('Unable to load JSON: %s' % (to_native(e)))
 | 
			
		||||
 | 
			
		||||
        if not result.get('success'):
 | 
			
		||||
            raise BitwardenException('API call failed: %s' % (result.get('data')))
 | 
			
		||||
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def is_unlocked(self):
 | 
			
		||||
        """Check if the Bitwarden vault is unlocked.
 | 
			
		||||
        """
 | 
			
		||||
        result = self._api_call('status')
 | 
			
		||||
        return result['data']['template']['status'] == 'unlocked'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def sync(self):
 | 
			
		||||
        """Pull the latest vault data from server.
 | 
			
		||||
        """
 | 
			
		||||
        return self._api_call('sync', method='POST')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_items(self, name, username=None, folder_id=None, collection_id=None, organisation_id=None):
 | 
			
		||||
        """Search for items in Bitwarden. Returns a list of the items that *exactly* matches all the parameters.
 | 
			
		||||
 | 
			
		||||
        A complete object:
 | 
			
		||||
        {
 | 
			
		||||
          "object": "item",
 | 
			
		||||
          "id": "60020baa-e876-4fd4-b5bc-259b5e6389a8",
 | 
			
		||||
          "organizationId": "44906ecb-b307-47a5-92b4-a097745592ed",
 | 
			
		||||
          "folderId": null,
 | 
			
		||||
          "type": 1,
 | 
			
		||||
          "reprompt": 0,
 | 
			
		||||
          "name": "myhost - purpose",
 | 
			
		||||
          "notes": "Generated by Ansible.",
 | 
			
		||||
          "favorite": false,
 | 
			
		||||
          "login": {
 | 
			
		||||
            "uris": [
 | 
			
		||||
              {
 | 
			
		||||
                "match": null,
 | 
			
		||||
                "uri": "https://www.example.com"
 | 
			
		||||
              }
 | 
			
		||||
            ],
 | 
			
		||||
            "username": "username",
 | 
			
		||||
            "password": "password",
 | 
			
		||||
            "totp": null,
 | 
			
		||||
            "passwordRevisionDate": null
 | 
			
		||||
          },
 | 
			
		||||
          "collectionIds": [
 | 
			
		||||
            "153e991a-a56f-4e5d-9dea-c13b9e693fc4"
 | 
			
		||||
          ],
 | 
			
		||||
          "revisionDate": "2022-06-26T06:00:00.000Z"
 | 
			
		||||
        }
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        params = urllib.parse.urlencode(
 | 
			
		||||
            {
 | 
			
		||||
                'search': name,
 | 
			
		||||
            },
 | 
			
		||||
            quote_via=urllib.parse.quote,
 | 
			
		||||
        )
 | 
			
		||||
        result = self._api_call('list/object/items?%s' % (params))
 | 
			
		||||
 | 
			
		||||
        # make sure that all the given parameters exactly match the requested one, as `bw` is not that precise (only performs a search)
 | 
			
		||||
        # we are not using the filter parameters of the `bw` utility, as they perform an OR operation, but we want AND
 | 
			
		||||
        matching_items = []
 | 
			
		||||
        for item in result['data']['data']:
 | 
			
		||||
            if item['name'] == name \
 | 
			
		||||
                and (item['login']['username'] == username) \
 | 
			
		||||
                and (item['folderId'] == folder_id) \
 | 
			
		||||
                and (
 | 
			
		||||
                    # cover case if collectionIds is an empty list
 | 
			
		||||
                    (collection_id is None and not item.get('collectionIds')) \
 | 
			
		||||
                    or \
 | 
			
		||||
                    (collection_id in item.get('collectionIds', [])) \
 | 
			
		||||
                ) \
 | 
			
		||||
                and (item['organizationId'] == organisation_id):
 | 
			
		||||
                matching_items.append(item)
 | 
			
		||||
 | 
			
		||||
        return matching_items
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_all_items(self):
 | 
			
		||||
        """Get a list with all item from Bitwarden.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        result = self._api_call('list/object/items')
 | 
			
		||||
        return result['data']['data']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_item_by_id(self, item_id):
 | 
			
		||||
        """Get an item by ID from Bitwarden. Returns the item or None. Throws an exception if the id leads to unambiguous results.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        result = self._api_call('object/item/%s' % (item_id))
 | 
			
		||||
        return result['data']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_collections(self):
 | 
			
		||||
        """Get a list with all collections from Bitwarden.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        result = self._api_call('list/object/collections')
 | 
			
		||||
        return result['data']['data']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_folders(self):
 | 
			
		||||
        """Get a list with all folders from Bitwarden.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        result = self._api_call('list/object/folders')
 | 
			
		||||
        return result['data']['data']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def generate(self, password_length=40,
 | 
			
		||||
        password_uppercase=True, password_lowercase=True,
 | 
			
		||||
        password_numeric=True, password_special=False):
 | 
			
		||||
        """Return a generated password.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        params = 'length=%i%s%s%s%s' % (
 | 
			
		||||
            password_length,
 | 
			
		||||
            '&uppercase' if password_uppercase else '',
 | 
			
		||||
            '&lowercase' if password_lowercase else '',
 | 
			
		||||
            '&number' if password_numeric else '',
 | 
			
		||||
            '&special' if password_special else '',
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        result = self._api_call('generate?%s' % (params))
 | 
			
		||||
        return result['data']['data']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_template_item_login_uri(self, uris):
 | 
			
		||||
        """Get an item.login.uri object from the vault.
 | 
			
		||||
 | 
			
		||||
        A complete object:
 | 
			
		||||
 | 
			
		||||
        {
 | 
			
		||||
          "match": null,
 | 
			
		||||
          "uri": "https://google.com"
 | 
			
		||||
        }
 | 
			
		||||
        """
 | 
			
		||||
        login_uris = []
 | 
			
		||||
        if uris:
 | 
			
		||||
            # To create uris, fetch the JSON structure for that.
 | 
			
		||||
            result = self._api_call('object/template/item.login.uri')
 | 
			
		||||
            template = result['data']['template']
 | 
			
		||||
            for uri in uris:
 | 
			
		||||
                login_uri = template.copy() # make sure we are not editing the same object repeatedly
 | 
			
		||||
                login_uri['uri'] = uri
 | 
			
		||||
                login_uris.append(login_uri)
 | 
			
		||||
 | 
			
		||||
        return login_uris
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_template_item_login(self, username=None, password=None, login_uris=None):
 | 
			
		||||
        """Get an item.login object from the vault.
 | 
			
		||||
 | 
			
		||||
        A complete object:
 | 
			
		||||
 | 
			
		||||
        {
 | 
			
		||||
          "uris": [],
 | 
			
		||||
          "username": "jdoe",
 | 
			
		||||
          "password": "myp@ssword123",
 | 
			
		||||
          "totp": "JBSWY3DPEHPK3PXP"
 | 
			
		||||
        }
 | 
			
		||||
        """
 | 
			
		||||
        # To create a login item, fetch the JSON structure for that.
 | 
			
		||||
        result = self._api_call('object/template/item.login')
 | 
			
		||||
        login = result['data']['template']
 | 
			
		||||
        login['password'] = password
 | 
			
		||||
        login['totp'] = ''
 | 
			
		||||
        login['uris'] = login_uris or []
 | 
			
		||||
        login['username'] = username
 | 
			
		||||
 | 
			
		||||
        return login
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def get_template_item(self, name, login=None, notes=None, organization_id=None, collection_ids=None, folder_id=None):
 | 
			
		||||
        """Get an item.login object from the vault.
 | 
			
		||||
 | 
			
		||||
        A complete item object:
 | 
			
		||||
 | 
			
		||||
        {
 | 
			
		||||
          "organizationId": null,
 | 
			
		||||
          "collectionIds": null,
 | 
			
		||||
          "folderId": null,
 | 
			
		||||
          "type": 1,
 | 
			
		||||
          "name": "Item name",
 | 
			
		||||
          "notes": "Some notes about this item.",
 | 
			
		||||
          "favorite": false,
 | 
			
		||||
          "fields": [],
 | 
			
		||||
          "login": null,
 | 
			
		||||
          "secureNote": null,
 | 
			
		||||
          "card": null,
 | 
			
		||||
          "identity": null,
 | 
			
		||||
          "reprompt": 0
 | 
			
		||||
        }
 | 
			
		||||
        """
 | 
			
		||||
        # To create an item later on, fetch the item JSON structure, and fill in the appropriate
 | 
			
		||||
        # values.
 | 
			
		||||
        result = self._api_call('object/template/item')
 | 
			
		||||
        item = result['data']['template']
 | 
			
		||||
        item['collectionIds'] = collection_ids
 | 
			
		||||
        item['folderId'] = folder_id
 | 
			
		||||
        item['login'] = login
 | 
			
		||||
        item['name'] = name
 | 
			
		||||
        item['notes'] = notes
 | 
			
		||||
        item['organizationId'] = organization_id
 | 
			
		||||
 | 
			
		||||
        return item
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def create_item(self, item):
 | 
			
		||||
        """Creates an item object in Bitwarden.
 | 
			
		||||
        """
 | 
			
		||||
        result = self._api_call('object/item', method='POST', body=item)
 | 
			
		||||
        return result['data']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def edit_item(self, item, item_id):
 | 
			
		||||
        """Edits an item object in Bitwarden.
 | 
			
		||||
        """
 | 
			
		||||
        result = self._api_call('object/item/%s' % (item_id), method='PUT', body=item)
 | 
			
		||||
        return result['data']
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def add_attachment(self, item_id, attachment_path):
 | 
			
		||||
        """Adds the file at `attachment_path` to the item specified by `item_id`
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
        body = {
 | 
			
		||||
            'file': {
 | 
			
		||||
                'filename': attachment_path,
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
        result = self._api_call('attachment?itemId=%s' % (item_id), method='POST', body=body, body_format='form-multipart')
 | 
			
		||||
        return result
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def get_pretty_name(name, hostname=None, purpose=None):
 | 
			
		||||
        """create a nice name for the item if none is given
 | 
			
		||||
        schemes:
 | 
			
		||||
        * hostname - purpose (for example "app4711 - MariaDB")
 | 
			
		||||
        * hostname (for example "app4711")
 | 
			
		||||
        """
 | 
			
		||||
        if not name:
 | 
			
		||||
            name = hostname
 | 
			
		||||
            if purpose:
 | 
			
		||||
                name += ' - {}'.format(purpose)
 | 
			
		||||
 | 
			
		||||
        return name
 | 
			
		||||
							
								
								
									
										85
									
								
								src/bitwarden_api/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/bitwarden_api/mod.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,85 @@
 | 
			
		||||
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,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										394
									
								
								src/main.py
									
									
									
									
									
								
							
							
						
						
									
										394
									
								
								src/main.py
									
									
									
									
									
								
							@ -1,394 +0,0 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
# -*- coding: utf-8; py-indent-offset: 4 -*-
 | 
			
		||||
#
 | 
			
		||||
# Author:  Linuxfabrik GmbH, Zurich, Switzerland
 | 
			
		||||
# Contact: info (at) linuxfabrik (dot) ch
 | 
			
		||||
#          https://www.linuxfabrik.ch/
 | 
			
		||||
# License: The Unlicense, see LICENSE file.
 | 
			
		||||
 | 
			
		||||
"""See the README for more details.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import argparse  # pylint: disable=C0413
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
import pyperclip
 | 
			
		||||
 | 
			
		||||
import bitwarden  # pylint: disable=C0413
 | 
			
		||||
import lib.base  # pylint: disable=C0413
 | 
			
		||||
import lib.shell  # pylint: disable=C0413
 | 
			
		||||
 | 
			
		||||
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
 | 
			
		||||
__version__ = '2024042101'
 | 
			
		||||
 | 
			
		||||
DESCRIPTION = """todo"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_args():
 | 
			
		||||
    """Parse command line arguments using argparse.
 | 
			
		||||
    """
 | 
			
		||||
    parser = argparse.ArgumentParser(description=DESCRIPTION)
 | 
			
		||||
 | 
			
		||||
    parser.add_argument(
 | 
			
		||||
        '-V', '--version',
 | 
			
		||||
        action='version',
 | 
			
		||||
        version='%(prog)s: v{} by {}'.format(__version__, __author__)
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Define subcommands
 | 
			
		||||
    subparsers = parser.add_subparsers(dest='COMMAND', required=True)
 | 
			
		||||
 | 
			
		||||
    # Select command
 | 
			
		||||
    select_parser = subparsers.add_parser('select', help='Select command')
 | 
			
		||||
    # we do not actually need the title, the variable is never used
 | 
			
		||||
    # however, rofi always calls the given executable with the title of the item,
 | 
			
		||||
    # so we need to accept it to prevent an argparse "unrecognized arguments" error
 | 
			
		||||
    select_parser.add_argument(
 | 
			
		||||
        help='Internal use only',
 | 
			
		||||
        dest='TITLE',
 | 
			
		||||
        nargs='?',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # History command with nested subcommands
 | 
			
		||||
    history_parser = subparsers.add_parser('history', help='History related commands')
 | 
			
		||||
    history_subparsers = history_parser.add_subparsers(
 | 
			
		||||
        help='History subcommands',
 | 
			
		||||
        dest='HISTORY_COMMAND',
 | 
			
		||||
        required=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    get_parser = history_subparsers.add_parser('get', help='Get a specific item from history')
 | 
			
		||||
    get_parser.add_argument(
 | 
			
		||||
        dest='HISTORY_GET_INDEX',
 | 
			
		||||
        metavar='index',
 | 
			
		||||
        type=int,
 | 
			
		||||
        help='Index of the item in history',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    get_parser.add_argument(
 | 
			
		||||
        dest='HISTORY_GET_FIELD',
 | 
			
		||||
        metavar='field',
 | 
			
		||||
        type=str,
 | 
			
		||||
        help='Field to retrieve',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    get_parser.add_argument(
 | 
			
		||||
        '--print',
 | 
			
		||||
        help='Print the field instead of saving it to the clipboard.',
 | 
			
		||||
        dest='HISTORY_GET_PRINT',
 | 
			
		||||
        action='store_true',
 | 
			
		||||
        default=False,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return parser.parse_args()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_history():
 | 
			
		||||
    file_path = '/tmp/bw-menu-history.json' # TODO: use XDG paths
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        with open(file_path, 'r', encoding='UTF-8') as file:
 | 
			
		||||
            content = file.read()
 | 
			
		||||
    except FileNotFoundError:
 | 
			
		||||
        # Return an empty list if the file cannot be opened
 | 
			
		||||
        return []
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        history = json.loads(content)
 | 
			
		||||
        return history['history']
 | 
			
		||||
    except json.JSONDecodeError:
 | 
			
		||||
        # Return an empty list if deserialization fails
 | 
			
		||||
        return []
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_history(entries):
 | 
			
		||||
    history = {'history': entries}
 | 
			
		||||
    json_str = json.dumps(history)
 | 
			
		||||
 | 
			
		||||
    file_path = '/tmp/bw-menu-history.json' # TODO: use XDG paths
 | 
			
		||||
 | 
			
		||||
    # Write the JSON string to a file, overwriting it if it already exists
 | 
			
		||||
    with open(file_path, 'w', encoding='UTF-8') as file:
 | 
			
		||||
        file.write(json_str)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
MAX_HISTORY_LEN = 5 # TODO: make configurable
 | 
			
		||||
def add_to_history(entry):
 | 
			
		||||
    history = get_history()
 | 
			
		||||
    history.insert(0, entry)
 | 
			
		||||
    if len(history) > MAX_HISTORY_LEN:
 | 
			
		||||
        history = history[:MAX_HISTORY_LEN]
 | 
			
		||||
    save_history(history)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def save_to_clipboard(string):
 | 
			
		||||
    pyperclip.copy(string)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_title(item, folders, collections):
 | 
			
		||||
    title = ''
 | 
			
		||||
 | 
			
		||||
    folder_id = item.get('folderId')
 | 
			
		||||
    if folder_id:
 | 
			
		||||
        title += f'{folders[folder_id]}/'
 | 
			
		||||
 | 
			
		||||
    # 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-db01", "001/000-p-db01"
 | 
			
		||||
    # get_title should return a list of titles which match the current item
 | 
			
		||||
    collection_ids = item.get('collectionIds', [])
 | 
			
		||||
    for collection_id in collection_ids:
 | 
			
		||||
        title += collections[collection_id]
 | 
			
		||||
    if collection_ids:
 | 
			
		||||
        title += '/'
 | 
			
		||||
 | 
			
		||||
    if not title:
 | 
			
		||||
        title += 'ungrouped/'
 | 
			
		||||
 | 
			
		||||
    title += item['name']
 | 
			
		||||
 | 
			
		||||
    username = item['login'].get('username')
 | 
			
		||||
    if username:
 | 
			
		||||
        title += f' ({username})'
 | 
			
		||||
 | 
			
		||||
    return title
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def generate_rofi_list(logins):
 | 
			
		||||
    print('\0prompt\x1fSelect item')
 | 
			
		||||
    print('\0message\x1fReturn | Copy password\rShift+Return | Copy username or selected field\rControl+Return | Show item')
 | 
			
		||||
    print('\0use-hot-keys\x1ftrue')
 | 
			
		||||
    print('\0no-custom\x1ftrue')
 | 
			
		||||
    print('\0markup-rows\x1ftrue')
 | 
			
		||||
 | 
			
		||||
    for title in sorted(logins.keys()):
 | 
			
		||||
        print(title, end='')
 | 
			
		||||
        icon = 'folder' # TODO: what icons are available? ~/.icons seems to work
 | 
			
		||||
        print(f'\0icon\x1f{icon}\x1finfo\x1f{logins[title]["id"]}')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_bw_collection_id2name_map():
 | 
			
		||||
    logger = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
    bw = bitwarden.Bitwarden()
 | 
			
		||||
    try:
 | 
			
		||||
        collections = bw.get_collections()
 | 
			
		||||
        # convert to lookup dictionary
 | 
			
		||||
        collections = {item['id']: item['name'] for item in collections}
 | 
			
		||||
        logger.debug('Got list of Bitwarden collections.')
 | 
			
		||||
    except Exception:
 | 
			
		||||
        logger.exception('Failed to get list of Bitwarden collections.')
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
    return collections
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_bw_folders_id2name_map():
 | 
			
		||||
    logger = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
    bw = bitwarden.Bitwarden()
 | 
			
		||||
    try:
 | 
			
		||||
        folders = bw.get_folders()
 | 
			
		||||
        # convert to lookup dictionary
 | 
			
		||||
        folders = {item['id']: item['name'] for item in folders}
 | 
			
		||||
        logger.debug('Got list of Bitwarden folders.')
 | 
			
		||||
    except Exception:
 | 
			
		||||
        logger.exception('Failed to get list of Bitwarden folders.')
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
    return folders
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_bitwarden_logins():
 | 
			
		||||
    logger = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
    bw = bitwarden.Bitwarden()
 | 
			
		||||
 | 
			
		||||
    if not bw.is_unlocked:
 | 
			
		||||
        logger.error('Not logged into Bitwarden, or Bitwarden Vault is locked. Please run `bw login` and `bw unlock` first.')
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        # TODO: add a timeout for running the sync?
 | 
			
		||||
        bw.sync()
 | 
			
		||||
        logger.debug('Synchronised the Bitwarden Vault.')
 | 
			
		||||
    except Exception:
 | 
			
		||||
        logger.exception('Failed to sync the Bitwarden Vault.')
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        items = bw.get_all_items()
 | 
			
		||||
        # we only want items which have a login
 | 
			
		||||
        items = [item for item in items if 'login' in item]
 | 
			
		||||
        logger.debug('Got list of Bitwarden items.')
 | 
			
		||||
    except Exception:
 | 
			
		||||
        logger.exception('Failed to get list of Bitwarden items.')
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    collections = get_bw_collection_id2name_map()
 | 
			
		||||
    folders = get_bw_folders_id2name_map()
 | 
			
		||||
 | 
			
		||||
    logins = {}
 | 
			
		||||
 | 
			
		||||
    for item in items:
 | 
			
		||||
        title = get_title(item, folders, collections)
 | 
			
		||||
        logins[title] = item
 | 
			
		||||
 | 
			
		||||
    return logins
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
#
 | 
			
		||||
# currently using the script mode so that we can pass additional info (eg the id).
 | 
			
		||||
# no other mode supports this, meaning we would need to match based on the title
 | 
			
		||||
def handle_select():
 | 
			
		||||
    logger = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
    rofi_retv = os.environ.get('ROFI_RETV', None)
 | 
			
		||||
    rofi_info = os.environ.get('ROFI_INFO', None)
 | 
			
		||||
 | 
			
		||||
    logger.debug(f'ROFI_RETV: {rofi_retv}')
 | 
			
		||||
    logger.debug(f'ROFI_INFO: {rofi_info}')
 | 
			
		||||
 | 
			
		||||
    if rofi_retv is None:
 | 
			
		||||
        # this means rofi is not running yet, so let's start it
 | 
			
		||||
        logger.debug('Starting initial rofi process')
 | 
			
		||||
        # we need to remap accept-alt and accept-custom so that we can use the keys for custom binds
 | 
			
		||||
        cmd = f'''
 | 
			
		||||
            rofi
 | 
			
		||||
            -show bw
 | 
			
		||||
            -modes 'bw:{sys.argv[0]} select'
 | 
			
		||||
            -matching fuzzy
 | 
			
		||||
            -sorting-method fzf
 | 
			
		||||
            -sort
 | 
			
		||||
            -kb-accept-alt F12
 | 
			
		||||
            -kb-accept-custom F11
 | 
			
		||||
            -kb-custom-1 Shift+Return
 | 
			
		||||
            -kb-custom-2 Control+Return
 | 
			
		||||
        '''
 | 
			
		||||
        cmd = cmd.replace('\n', ' ')
 | 
			
		||||
        logger.debug(f'Running {cmd}')
 | 
			
		||||
        _, stderr, retc = lib.base.coe(lib.shell.shell_exec(cmd))
 | 
			
		||||
        if retc != 0 or stderr: # TODO: better error handling
 | 
			
		||||
            logger.error(f'retc: {retc}, stderr: {stderr}')
 | 
			
		||||
            sys.exit(1)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    else:
 | 
			
		||||
        # this means we are being called by rofi as a custom script
 | 
			
		||||
        # either generate the list, or act on input
 | 
			
		||||
        rofi_retv = int(rofi_retv)
 | 
			
		||||
 | 
			
		||||
        # 0: Initial call of script
 | 
			
		||||
        if rofi_retv == 0:
 | 
			
		||||
            generate_rofi_list(get_bitwarden_logins())
 | 
			
		||||
 | 
			
		||||
        else:
 | 
			
		||||
            try:
 | 
			
		||||
                bw = bitwarden.Bitwarden()
 | 
			
		||||
                item = bw.get_item_by_id(rofi_info)
 | 
			
		||||
            except Exception:
 | 
			
		||||
                logger.exception(f'Failed to get Bitwarden item with id {rofi_info}')
 | 
			
		||||
                sys.exit(1)
 | 
			
		||||
 | 
			
		||||
            add_to_history(rofi_info)
 | 
			
		||||
 | 
			
		||||
            # 1: Selected an entry
 | 
			
		||||
            if rofi_retv == 1:
 | 
			
		||||
                logger.info('Copying password')
 | 
			
		||||
                password = item['login']['password']
 | 
			
		||||
                save_to_clipboard(password)
 | 
			
		||||
 | 
			
		||||
            # 10-28: Custom keybinding 1-19
 | 
			
		||||
            elif rofi_retv == 10:
 | 
			
		||||
                logger.info('Copying username')
 | 
			
		||||
                username = item['login']['username']
 | 
			
		||||
                save_to_clipboard(username)
 | 
			
		||||
 | 
			
		||||
            elif rofi_retv == 11:
 | 
			
		||||
                logger.info('Showing item')
 | 
			
		||||
                print('\0prompt\x1fSelect field')
 | 
			
		||||
                print('\0message\x1fReturn | Copy field')
 | 
			
		||||
                print('{}\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
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                print(f'Unknown rofi_retv: {rofi_retv}')
 | 
			
		||||
                sys.exit(2)
 | 
			
		||||
 | 
			
		||||
        sys.exit(0)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def handle_history_get(index, field, print_result=False):
 | 
			
		||||
    logger = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
    history = get_history()
 | 
			
		||||
    if index >= len(history):
 | 
			
		||||
        print(f'Trying to get history element {index}, but the history only contains {len(history)} elements')
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    item_id = history[index]
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        bw = bitwarden.Bitwarden()
 | 
			
		||||
        item = bw.get_item_by_id(item_id)
 | 
			
		||||
    except Exception:
 | 
			
		||||
        logger.exception(f'Failed to get Bitwarden item with id "{item_id}". It might not exist anymore.')
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    login = item.get('login')
 | 
			
		||||
    if login is None:
 | 
			
		||||
        logger.error(f'The Bitwarden item with id "{item_id}" does not have a login attribute.')
 | 
			
		||||
        sys.exit(2)
 | 
			
		||||
 | 
			
		||||
    result = None
 | 
			
		||||
    if field == 'title':
 | 
			
		||||
        collections = get_bw_collection_id2name_map()
 | 
			
		||||
        folders = get_bw_folders_id2name_map()
 | 
			
		||||
        title = get_title(item, folders, collections)
 | 
			
		||||
        result = title
 | 
			
		||||
 | 
			
		||||
    elif field in login:
 | 
			
		||||
        result = login[field]
 | 
			
		||||
    else:
 | 
			
		||||
        print(f"Could not find a field named '{field}'")
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    if print_result:
 | 
			
		||||
        print(result)
 | 
			
		||||
    else:
 | 
			
		||||
        save_to_clipboard(result)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    """The main function. Hier spielt die Musik.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    logging_format = "[%(filename)s->%(funcName)s:%(lineno)s] %(message)s"
 | 
			
		||||
    logging.basicConfig(
 | 
			
		||||
        format=logging_format,
 | 
			
		||||
        # level=logging.DEBUG,
 | 
			
		||||
    )
 | 
			
		||||
    logger = logging.getLogger()
 | 
			
		||||
 | 
			
		||||
    logger.debug(sys.argv)
 | 
			
		||||
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    if args.COMMAND == 'select':
 | 
			
		||||
        handle_select()
 | 
			
		||||
    elif args.COMMAND == 'history':
 | 
			
		||||
        if args.HISTORY_COMMAND == 'get':
 | 
			
		||||
            handle_history_get(args.HISTORY_GET_INDEX, args.HISTORY_GET_FIELD, args.HISTORY_GET_PRINT)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    main()
 | 
			
		||||
							
								
								
									
										531
									
								
								src/main.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										531
									
								
								src/main.rs
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,531 @@
 | 
			
		||||
use clap::{Parser, Subcommand};
 | 
			
		||||
use itertools::Itertools;
 | 
			
		||||
use log::{debug, error, info};
 | 
			
		||||
use serde::{Serialize, Deserialize};
 | 
			
		||||
use std::env;
 | 
			
		||||
use std::fs;
 | 
			
		||||
use std::io::Read;
 | 
			
		||||
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 {
 | 
			
		||||
        title: Option<String>,
 | 
			
		||||
    },
 | 
			
		||||
    History {
 | 
			
		||||
        #[command(subcommand)]
 | 
			
		||||
        command: HistoryCommands
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Subcommand)]
 | 
			
		||||
enum HistoryCommands {
 | 
			
		||||
    List {},
 | 
			
		||||
    Get {
 | 
			
		||||
        index: usize,
 | 
			
		||||
        field: String,
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(Serialize, Deserialize)]
 | 
			
		||||
struct History {
 | 
			
		||||
    history: Vec<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");
 | 
			
		||||
            handle_select();
 | 
			
		||||
        }
 | 
			
		||||
        CliCommands::History { command } => match &command {
 | 
			
		||||
            HistoryCommands::List {} => {
 | 
			
		||||
                debug!("Running history list");
 | 
			
		||||
                handle_history_list();
 | 
			
		||||
            }
 | 
			
		||||
            HistoryCommands::Get {index, field} => {
 | 
			
		||||
                debug!("Running history get");
 | 
			
		||||
                handle_history_get(*index, field);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
fn get_history() -> Vec<String> {
 | 
			
		||||
    let file_path = "/tmp/bw-menu-history.json"; // TODO: use XDG paths
 | 
			
		||||
 | 
			
		||||
    let read_file = fs::OpenOptions::new()
 | 
			
		||||
        .read(true)
 | 
			
		||||
        .open(file_path);
 | 
			
		||||
 | 
			
		||||
    let json_str = match read_file {
 | 
			
		||||
        Ok(mut file) => {
 | 
			
		||||
            let mut content = String::new();
 | 
			
		||||
            file.read_to_string(&mut content).expect("Failed to read file");
 | 
			
		||||
            content
 | 
			
		||||
        }
 | 
			
		||||
        Err(_) => String::new(), // Return an empty string if the file cannot be opened
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // Deserialize JSON string back into a Rust struct
 | 
			
		||||
    let history: History = serde_json::from_str(&json_str).unwrap_or_else(|_| {
 | 
			
		||||
        // Return a default Person with an empty history if deserialization fails
 | 
			
		||||
        History {
 | 
			
		||||
            history: Vec::new(),
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    history.history
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
fn save_history(entries: Vec<String>) {
 | 
			
		||||
    let history = History {
 | 
			
		||||
        history: entries,
 | 
			
		||||
    };
 | 
			
		||||
    let json_str = serde_json::to_string(&history).expect("Failed to serialize to JSON!");
 | 
			
		||||
 | 
			
		||||
    let file_path = "/tmp/bw-menu-history.json"; // TODO: use XDG paths
 | 
			
		||||
 | 
			
		||||
    // Write the JSON string to a file, overwriting it if it already exists
 | 
			
		||||
    let mut file = fs::OpenOptions::new()
 | 
			
		||||
        .write(true)
 | 
			
		||||
        .truncate(true) // Ensure the file is truncated before writing
 | 
			
		||||
        .create(true)
 | 
			
		||||
        .open(file_path)
 | 
			
		||||
        .expect("Failed to open file for writing!");
 | 
			
		||||
 | 
			
		||||
    file.write_all(json_str.as_bytes()).expect("Failed to write to file!");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MAX_HISTORY_LEN: usize = 5; // TODO: make configurable
 | 
			
		||||
fn add_history(entry: &String) {
 | 
			
		||||
    let mut history = get_history();
 | 
			
		||||
    history.insert(0, entry.to_string());
 | 
			
		||||
    if history.len() > MAX_HISTORY_LEN {
 | 
			
		||||
        history = history[0..MAX_HISTORY_LEN].to_vec();
 | 
			
		||||
    }
 | 
			
		||||
    save_history(history);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// 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
 | 
			
		||||
//
 | 
			
		||||
// currently using the script mode so that we can pass additional info (eg the id).
 | 
			
		||||
// no other mode supports this, meaning we would need to match based on the title
 | 
			
		||||
fn handle_select() {
 | 
			
		||||
    let rofi_retv = env::var("ROFI_RETV");
 | 
			
		||||
    let rofi_info = env::var("ROFI_INFO");
 | 
			
		||||
    dbg!(&rofi_retv);
 | 
			
		||||
    dbg!(&rofi_info);
 | 
			
		||||
 | 
			
		||||
    let executable_path = env::current_exe().unwrap_or_else(|err| {
 | 
			
		||||
        error!("Failed to get the path to the executable: {:?}", err);
 | 
			
		||||
        process::exit(2);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    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(format!("bw:{} select", &executable_path.to_string_lossy()))
 | 
			
		||||
                .arg("-matching")
 | 
			
		||||
                .arg("fuzzy")
 | 
			
		||||
                .arg("-sorting-method")
 | 
			
		||||
                .arg("fzf")
 | 
			
		||||
                .arg("-sort")
 | 
			
		||||
                // 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);
 | 
			
		||||
                    }
 | 
			
		||||
                };
 | 
			
		||||
                add_history(&rofi_info);
 | 
			
		||||
                // 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('/');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if title.is_empty() {
 | 
			
		||||
        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),
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
fn handle_history_list() {
 | 
			
		||||
    // TODO: not sure if we need this, would make more sense to just show these entries at the top
 | 
			
		||||
    // of the normal selector
 | 
			
		||||
    let history = get_history();
 | 
			
		||||
 | 
			
		||||
    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 id in history {
 | 
			
		||||
        dbg!(&id);
 | 
			
		||||
        let item = match crate::bitwarden_api::object_item(&id) {
 | 
			
		||||
            Ok(response) => response.data,
 | 
			
		||||
            Err(msg) => {
 | 
			
		||||
                error!("Failed to get Bitwarden item with id {}. It might not exist anymore.\n{}", id, msg);
 | 
			
		||||
                continue;
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        if item.login.is_some() {
 | 
			
		||||
            let title = get_title(&item, &folders, &collections);
 | 
			
		||||
            debug!("Login: {}", title);
 | 
			
		||||
            logins.insert(title, item);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    dbg!(&logins);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
fn handle_history_get(index: usize, field: &String) {
 | 
			
		||||
    let history = get_history();
 | 
			
		||||
    if index >= history.len() {
 | 
			
		||||
        eprintln!("Trying to get history element {}, but the history only contains {} elements", index, history.len());
 | 
			
		||||
        process::exit(1);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let id = &history[index];
 | 
			
		||||
 | 
			
		||||
    let item = match crate::bitwarden_api::object_item(&id) {
 | 
			
		||||
        Ok(response) => response.data,
 | 
			
		||||
        Err(msg) => {
 | 
			
		||||
            error!("Failed to get Bitwarden item with id {}. It might not exist anymore.\n{}", id, msg);
 | 
			
		||||
            process::exit(1);
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
    // dbg!(&item.login);
 | 
			
		||||
    // dbg!(&field);
 | 
			
		||||
 | 
			
		||||
    match item.login {
 | 
			
		||||
        Some(ref login) => {
 | 
			
		||||
            match field.as_ref() {
 | 
			
		||||
                "uris" => {
 | 
			
		||||
                    if let Some(uris) = &login.uris {
 | 
			
		||||
                        println!("{:?}", uris);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                "username" => {
 | 
			
		||||
                    if let Some(username) = &login.username {
 | 
			
		||||
                        println!("{}", username);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                "password" => {
 | 
			
		||||
                    if let Some(password) = &login.password {
 | 
			
		||||
                        println!("{}", password);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                "totp" => {
 | 
			
		||||
                    if let Some(totp) = &login.totp {
 | 
			
		||||
                        println!("{}", totp);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                "title" => {
 | 
			
		||||
                    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 title = get_title(&item, &folders, &collections);
 | 
			
		||||
                    println!("{}", title);
 | 
			
		||||
                }
 | 
			
		||||
                _ =>  {
 | 
			
		||||
                    eprintln!("Could not find a field named '{}'", field);
 | 
			
		||||
                    process::exit(1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        None => {
 | 
			
		||||
            error!("Login of Bitwarden item is none");
 | 
			
		||||
            process::exit(2);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user