From ffa4d71ce26c9b1b8d580f135bc69b618553dbc1 Mon Sep 17 00:00:00 2001 From: Izalia Mae Date: Wed, 10 Aug 2022 18:02:20 -0400 Subject: [PATCH] add support for multiple password backends --- Makefile | 7 + barkshark_web/__init__.py | 1 + barkshark_web/component/application.py | 46 +- barkshark_web/component/web_context.py | 2 + barkshark_web/component/web_tab.py | 2 +- .../component/web_tab_webview_handler.py | 2 +- barkshark_web/database/base.py | 72 +-- barkshark_web/enums.py | 76 ++- barkshark_web/exceptions.py | 10 + barkshark_web/localweb/js/functions.js | 12 +- barkshark_web/localweb/macro.haml | 23 + .../localweb/page/password-edit.haml | 5 +- barkshark_web/localweb/page/passwords.haml | 2 +- barkshark_web/localweb/page/preferences.haml | 32 +- barkshark_web/passwords/__init__.py | 10 + barkshark_web/passwords/base.py | 257 ++++++++++ barkshark_web/passwords/bitwarden/__init__.py | 3 + barkshark_web/passwords/bitwarden/item.py | 200 ++++++++ barkshark_web/passwords/bitwarden/result.py | 6 + barkshark_web/passwords/bitwarden/storage.py | 439 ++++++++++++++++++ barkshark_web/passwords/gnome_keyring.py | 311 +++++++++++++ barkshark_web/passwords/sql.py | 182 ++++++++ barkshark_web/protocol/__init__.py | 1 + barkshark_web/protocol/handler.py | 44 +- barkshark_web/protocol/local.py | 71 ++- barkshark_web/protocol/local_wasm.py | 132 ++++++ barkshark_web/resources/main.ui | 174 +++++++ pyvenv.json | 14 +- requirements.txt | 2 + setup.cfg | 8 +- 30 files changed, 2005 insertions(+), 141 deletions(-) create mode 100644 barkshark_web/passwords/__init__.py create mode 100644 barkshark_web/passwords/base.py create mode 100644 barkshark_web/passwords/bitwarden/__init__.py create mode 100644 barkshark_web/passwords/bitwarden/item.py create mode 100644 barkshark_web/passwords/bitwarden/result.py create mode 100644 barkshark_web/passwords/bitwarden/storage.py create mode 100644 barkshark_web/passwords/gnome_keyring.py create mode 100644 barkshark_web/passwords/sql.py create mode 100644 barkshark_web/protocol/local_wasm.py diff --git a/Makefile b/Makefile index 55aa8be..59e031c 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ clean: find . -name '*.pyc' -exec rm --force {} + find . -name '*.pyo' -exec rm --force {} + rm --recursive --force $(VENV) + rm barkshark_web/bin/bw build-ext: mkdir --parent $(SRCNAME)/bin @@ -28,6 +29,12 @@ clean-ext: rm --force $(PY_LOADER) rm --recursive --force webextension/build +dl-bw: + wget "https://vault.bitwarden.com/download/?app=cli&platform=linux" -O /tmp/bw.zip + unzip /tmp/bw.zip -d barkshark_web/bin/ + rm /tmp/bw.zip + chmod +x barkshark_web/bin/bw + aptdeps: sudo apt install `cat apt-requirements.txt | xargs` --no-install-recommends -y diff --git a/barkshark_web/__init__.py b/barkshark_web/__init__.py index 7c97abb..5e09070 100644 --- a/barkshark_web/__init__.py +++ b/barkshark_web/__init__.py @@ -42,6 +42,7 @@ izzylib.add_builtins( get_app = lambda : Gio.Application.get_default() ) +scriptpath = Path(__file__).resolve().parent proto = 'pyweb' var = ObjectBase( readonly_props = True, diff --git a/barkshark_web/component/application.py b/barkshark_web/component/application.py index 93733cf..04ed7a0 100644 --- a/barkshark_web/component/application.py +++ b/barkshark_web/component/application.py @@ -9,13 +9,14 @@ from urllib.parse import quote from .web_context import WebContext from .window import Window -from .. import dbus, var +from .. import dbus, scriptpath, var from .. import __version__ as version, __software__ as swname from ..cookies import get_cookie_db from ..database import get_database from ..enums import LibraryPage from ..exceptions import AccountNotFoundError, NoAccountsError from ..functions import connect +from ..passwords import PASSWORD_STORAGE class Application(Gtk.Application): @@ -35,6 +36,7 @@ class Application(Gtk.Application): self.cookies = None self.context = None self.window = None + self.password = None self.setup(profile) @@ -45,7 +47,6 @@ class Application(Gtk.Application): def setup(self, profile): - script = Path(__file__).parent.parent data = Path.app_data_dir('config', 'barkshark', 'web') profilepath = data.join(profile or 'DEFAULT') @@ -55,11 +56,11 @@ class Application(Gtk.Application): # misc paths downloads = Path('~/Downloads'), data = data, - script = script, + script = scriptpath, # browser paths - localweb = script.join('localweb'), - resources = script.join('resources'), + localweb = scriptpath.join('localweb'), + resources = scriptpath.join('resources'), # data paths profile = profilepath, @@ -126,6 +127,39 @@ class Application(Gtk.Application): }) + def setup_password_storage(self, passtype=None): + if self.password: + self.password.stop() + self.password = None + + with self.db.session as s: + config = s.get_config() + + if not passtype: + passtype = config.pass_type + + storeclass = PASSWORD_STORAGE[passtype] + + if passtype == 'bitwarden': + self.password = storeclass( + host = config.bw_host, + port = config.bw_port, + external = config.bw_external, + session_key = config.bw_session_key + ) + + self.password.start() + + elif passtype == 'gnome': + self.password = storeclass() + + elif passtype == 'default': + self.password = storeclass(self.db) + + else: + raise TypeError(f'Not a valid password type: {passtype}') + + def get_account_by_handle(self, username, domain=None): if not len(self.accounts): raise NoAccountsError('No accounts') @@ -186,6 +220,8 @@ class Application(Gtk.Application): self.add_window(self.window) + self.setup_password_storage() + with self.db.session as s: self.accounts = s.fetch('accounts').all() diff --git a/barkshark_web/component/web_context.py b/barkshark_web/component/web_context.py index 9bbb682..3990d48 100644 --- a/barkshark_web/component/web_context.py +++ b/barkshark_web/component/web_context.py @@ -34,11 +34,13 @@ class WebContext(WebKit2.WebContext): ## Register local uri schemes self.security_manager = self.get_security_manager() self.security_manager.register_uri_scheme_as_local(var.local) #Local webui + self.security_manager.register_uri_scheme_as_local('bsweb://') self.security_manager.register_uri_scheme_as_local('local://') #Renamed "file://" handler self.security_manager.register_uri_scheme_as_secure('oauth://') ## Setup custom protocols self.register_uri_scheme(var.local_proto, protocol.Local) + self.register_uri_scheme('bsweb', protocol.LocalWeb) self.register_uri_scheme('sftp', protocol.Sftp) self.register_uri_scheme('source', protocol.Source) self.register_uri_scheme(protocol.Oauth.protocol, protocol.Oauth) diff --git a/barkshark_web/component/web_tab.py b/barkshark_web/component/web_tab.py index 31471ed..77d26a5 100644 --- a/barkshark_web/component/web_tab.py +++ b/barkshark_web/component/web_tab.py @@ -328,7 +328,7 @@ class WebTab(BuilderBase, Gtk.Box): def set_url(self, url=None): self['navbar-url'].set_text( - url or self.webview.get_uri() + url or self.webview.get_uri() or '' ) diff --git a/barkshark_web/component/web_tab_webview_handler.py b/barkshark_web/component/web_tab_webview_handler.py index 840f558..cc3b9ee 100644 --- a/barkshark_web/component/web_tab_webview_handler.py +++ b/barkshark_web/component/web_tab_webview_handler.py @@ -393,7 +393,7 @@ class WebviewHandler(ComponentBase): if error.domain == 'WebKitPolicyError' and 'interrupted' in error.message.lower(): if not url.mimetype: - self.window.notification(f'Cannot open url: {url}') + self.window.notification(f'Cannot open url: {url}', 'warning') elif not self.webview.can_show_mime_type(url.mimetype): self.app.context.download_uri(url) diff --git a/barkshark_web/database/base.py b/barkshark_web/database/base.py index accd192..5548ca2 100644 --- a/barkshark_web/database/base.py +++ b/barkshark_web/database/base.py @@ -36,6 +36,7 @@ default_config = { 'load_tabs': (True, 'bool'), 'local_underline_links': (False, 'bool'), 'maximized': (True, 'bool'), + 'pass_type': ('bitwarden', 'str'), 'post_check': (False, 'bool'), 'search': ('ddg', 'str'), 'scale': (1.0, 'float'), @@ -43,7 +44,13 @@ default_config = { 'location': (DotDict({'x': None, 'y': None}), 'dict'), 'tab_after_current': (True, 'bool'), 'tab_side': ('TOP', 'str'), - 'theme': ('default', 'str') + 'theme': ('default', 'str'), + + ## Bitwarden storage config + 'bw_host': ('localhost', 'str'), + 'bw_port': (None, 'int'), + 'bw_external': (False, 'bool'), + 'bw_session_key': (None, 'str') } default_permissions = { @@ -176,7 +183,6 @@ tables = { Column('username', 'text', nullable=False), Column('password', 'text', nullable=False), Column('label', 'text', nullable=False), - Column('domain', 'text', nullable=False), Column('url', 'text'), Column('note', 'text'), Column('created', 'datetime', nullable=False), @@ -294,10 +300,6 @@ class CustomSession(Session): return self.get_cached('history', url, url=url) - def get_password(self, username, host): - return self.fetch('passwords', username=username, domain=host).one() - - def get_permission(self, domain, cache=True): row = self.get_cached('siteoptions', domain, domain = domain) @@ -454,59 +456,7 @@ class CustomSession(Session): def put_history_from_tab(self, tab): - return self.put_history(tab.url, tab.title, (True if tab.fedi_post else False)) - - - def put_password(self, username, domain, password, url=None, label=None, note=None): - data = DotDict( - username = username, - domain = domain, - password = password, - created = datetime.now() - ) - - for key, value in (('url', url), ('label', label), ('note', note)): - if value: - data[key] = value - - if (row := self.get_password(username, domain)): - return self.put_password_row(row, **data) - - if not data.get('label'): - data['label'] = f'{username} @ {domain}' - - self.insert('passwords', **data) - - return self.get_password(username, domain) - - - def put_password_row(self, row, **kwargs): - for key in kwargs.keys(): - if key not in password_fields: - raise KeyError(f'Invalid password field: {key}') - - kwargs.update({'modified': datetime.now()}) - self.update_row(row, **kwargs) - row.update(**kwargs) - - return row - - - def put_passfield(self, url, userfield, passfield): - if None in [userfield, passfield]: - logging.verbose('Not inserting empty login fields') - return - - row = self.s.get.passfield(url) - - data = { - 'url': url.without_query, - 'domain': url.domain, - 'userfield': userfield, - 'passfield': passfield - } - - return self.put_cached('passfields', url.without_query, self.get_passfield(url), **data) + return self.put_history(tab.url, tab.title, (True if tab._data.post else False)) def put_permission(self, domain, key, value): @@ -578,10 +528,6 @@ class CustomSession(Session): return rows_affected - def del_passfield(self, domain): - self.del_cached('passfields', domain, domain=domain) - - def del_permission(self, domain): self.del_cached('siteoptions', domain, domain=domain) diff --git a/barkshark_web/enums.py b/barkshark_web/enums.py index e3b138a..0605eeb 100644 --- a/barkshark_web/enums.py +++ b/barkshark_web/enums.py @@ -1,6 +1,77 @@ -from enum import Enum +from enum import Enum, IntEnum +__all__ = [] + + +def register(enum): + __all__.append(enum.__name__) + return enum + + +@register +class BitwardenFieldType(IntEnum): + TEXT = 0 + HIDDEN = 1 + BOOLEAN = 2 + + +@register +class BitwardenItemType(IntEnum): + LOGIN = 1 + NOTE = 2 + CARD = 3 + IDENTITY = 4 + + +@register +class BitwardenLoginType(IntEnum): + AUTHENTICATOR = 0 + EMAIL = 1 + YUBIKEY = 3 + + +@register +class BitwardenTemplateType(Enum): + ITEM = 'item' + FIELD = 'item.field' + LOGIN = 'item.login' + URL = 'item.login.uri' + CARD = 'item.card' + IDENTITY = 'item.identity' + NOTE = 'item.securenote' + FOLDER = 'folder' + COLLECTION = 'collection' + ITEM_COLLECTION = 'item-collections' + ORG_COLLECTION = 'org-collection' + + +@register +class BitwardenUserStatusType(IntEnum): + INVITED = 0 + ACCEPTED = 1 + CONFIRMED = 2 + + +@register +class BitwardenUserType(IntEnum): + OWNER = 0 + ADMIN = 1 + USER = 2 + MANAGER = 3 + + +@register +class BitwardenUrlMatchType(IntEnum): + DOMAIN = 0 + HOST = 1 + STARTS_WITH = 2 + EXACT = 3 + REGEX = 4 + NEVER = 5 + + +@register class EditAction(Enum): COPY = WebKit2.EDITING_COMMAND_COPY LINK = WebKit2.EDITING_COMMAND_CREATE_LINK @@ -13,6 +84,7 @@ class EditAction(Enum): UNDO = WebKit2.EDITING_COMMAND_UNDO +@register class Javascript(Enum): SELECTION = 'window.getSelection().toString()' EXEC = 'window.execCommand("{}")' @@ -21,6 +93,7 @@ class Javascript(Enum): DELETE = 'document.activeElement.setRangeText("")' +@register class LibraryPage(Enum): HOME = '' BOOKMARKS = 'bookmarks' @@ -34,6 +107,7 @@ class LibraryPage(Enum): HELP = 'help' +@register class WebviewContextActions(Enum): AUDIO_COPY = WebKit2.ContextMenuAction.COPY_AUDIO_LINK_TO_CLIPBOARD AUDIO_DOWNLOAD = WebKit2.ContextMenuAction.DOWNLOAD_AUDIO_TO_DISK diff --git a/barkshark_web/exceptions.py b/barkshark_web/exceptions.py index 80bd903..efaf046 100644 --- a/barkshark_web/exceptions.py +++ b/barkshark_web/exceptions.py @@ -3,3 +3,13 @@ class NoAccountsError(Exception): class AccountNotFoundError(Exception): 'Raise when a specific account is not found' + +class BitwardenApiError(Exception): + 'Raise when a Bitwarden command fails' + +class NoPasswordError(Exception): + 'Raise when there is not result when fetching a single password row' + + def __init__(self, item_id): + Exception.__init__(self, f'No row for password with ID: {item_id}') + self.item_id = item_id diff --git a/barkshark_web/localweb/js/functions.js b/barkshark_web/localweb/js/functions.js index 97bc8d9..2b2fc78 100644 --- a/barkshark_web/localweb/js/functions.js +++ b/barkshark_web/localweb/js/functions.js @@ -1,10 +1,18 @@ // General function connect_event(name, signal, callback) { - const element = document.getElementById(name); + var element = document.getElementById(name); element.addEventListener(signal, callback); } +function trigger_event(name, type) { + var element = document.getElementById(name); + var event = new Event(type); + + element.dispatchEvent(event); +} + + function delete_item(base_url, id) { request(`${base_url}/${id}`, (response, body) => { if (response.status != 200) {return;} @@ -86,6 +94,8 @@ function handle_save_config(event) { request(url, (response, body) => { if (response.status == 200) { console.log(`Set config: ${input.id}=${value}`); + } else { + console.log(`Failed to set config: ${input.id}`) } }); } diff --git a/barkshark_web/localweb/macro.haml b/barkshark_web/localweb/macro.haml index e82f8a5..099d304 100644 --- a/barkshark_web/localweb/macro.haml +++ b/barkshark_web/localweb/macro.haml @@ -20,6 +20,29 @@ connect_event('{{key}}', 'change', handle_save_config); +-macro config_dropdown(label, key, value, options): + %label for='{{key}}' << {{label}} + %select id='{{key}}' name='{{key}}' + -for option in options: + -if value == option: + %option value='{{option}}' selected << {{option.title()}} + + -else + %option value='{{option}}' << {{option.title()}} + + %script type='application/javascript' + connect_event('{{key}}', 'change', handle_save_config) + + +-macro config_number(label, key, value, min=0, max=100): + %label for='{{key}}' << {{label}} + %input type='number' id='{{key}}' name='{{key}}' value='{{value}}' min='{{min}}' max='{{max}}' + + %script type='application/javascript' + connect_event('{{key}}', 'focusout', handle_save_config); + connect_event('{{key}}', 'keypress', handle_key_enter); + + -macro menu_item(label, path, icon): %a.menu-item.grid-container href='{{var.local}}{{path}}' %img.image.grid-item src='{{var.local}}/icon/{{icon}}' title='{{label}}' diff --git a/barkshark_web/localweb/page/password-edit.haml b/barkshark_web/localweb/page/password-edit.haml index ba8b6b1..4680e2a 100644 --- a/barkshark_web/localweb/page/password-edit.haml +++ b/barkshark_web/localweb/page/password-edit.haml @@ -24,9 +24,6 @@ %label.grid-item for='password' << Password %input#password.grid-item id='password' class='full-width' type='password' name='password' placeholder='password' value='{{password.password}}' - %label.grid-item for='domain' << Domain - %input.grid-item id='domain' class='full-width' type='text' name='domain' placeholder='domain' value='{{password.domain}}' - %label.grid-item for='url' << Url %input.grid-item id='url' class='full-width' type='text' name='url' placeholder='url' value='{{password.url or ""}}' @@ -37,7 +34,7 @@ %a.button onclick='javascript:document.getElementsByTagName("form")[0].submit();' << Save -if password.id - %a.button onclick='wine.location="{{var.local}}/passwords/delete/{{password.id}}?redir=/passwords"' << Delete + %a.button onclick='window.location="{{var.local}}/passwords/delete/{{password.id}}?redir=/passwords"' << Delete %a.button onclick='window.location.href="{{var.local}}/passwords"' << Cancel diff --git a/barkshark_web/localweb/page/passwords.haml b/barkshark_web/localweb/page/passwords.haml index da1ef42..696003c 100644 --- a/barkshark_web/localweb/page/passwords.haml +++ b/barkshark_web/localweb/page/passwords.haml @@ -5,7 +5,7 @@ %link rel='stylesheet' type='text/css' href='{{var.local}}/css/passwords.css' -block content - %form.buttons + %form#search-form.buttons %a.button onclick='toggle_all_details("item", true)' << Open %a.button onclick='toggle_all_details("item", false)' << Close %a.button onclick='window.location.href="{{var.local}}/passwords/edit"' << New diff --git a/barkshark_web/localweb/page/preferences.haml b/barkshark_web/localweb/page/preferences.haml index d45f9e9..3267504 100644 --- a/barkshark_web/localweb/page/preferences.haml +++ b/barkshark_web/localweb/page/preferences.haml @@ -1,6 +1,6 @@ -set page = 'Preferences' -extends 'base.haml' --from 'macro.haml' import config_checkbox, config_dirpicker, config_textbox +-from 'macro.haml' import config_checkbox, config_textbox, config_dropdown, config_number -block meta %link rel='stylesheet' type='text/css' href='{{var.local}}/css/preferences.css' @@ -23,6 +23,36 @@ %p -> =config_checkbox('Load all tabs on startup', 'load_tabs', config.load_tabs) %p -> =config_checkbox('Load tab when switching if it is unloaded', 'load_switch', config.load_switch) + %details.category.section open + %summary << Passwor Storage + .container + %p -> =config_dropdown('Password storage backend', 'pass_type', config.pass_type, pass_backends) + + %details.category.section {{'open' if app.password.storage_type == 'bitwarden' else ''}} + %summary << Password Storage: Bitwarden + .container + -if app.password.storage_type == 'bitwarden' + %p + Account E-Mail: + %input type='text' value='{{app.password.account}}' disabled + + %p + Unlocked: + -if app.password.session_key + %input type='checkbox' disabled checked + + -else + %input type='checkbox' disabled + + %hr + + %p -> =config_textbox('Hostname for the api server to listen on', 'bw_host', config.bw_host) + %p + =config_number('Port for the api server to listen on', 'bw_port', config.bw_port, 1024, 65535) + %input type='button' onclick='document.getElementById("bw_port").value = "0";trigger_event("bw_port", "focusout")' value='Random' + + %p -> =config_checkbox('Use an external api server', 'bw_external', config.bw_external) + %details.category.section open %summary << Advanced .container diff --git a/barkshark_web/passwords/__init__.py b/barkshark_web/passwords/__init__.py new file mode 100644 index 0000000..532a005 --- /dev/null +++ b/barkshark_web/passwords/__init__.py @@ -0,0 +1,10 @@ +PASSWORD_STORAGE = DotDict() + +from .bitwarden import BitwardenStorage, BitwardenItem, BitwardenResult +from .gnome_keyring import GnomeKeyringStorage +from .sql import SqlStorage + + +PASSWORD_STORAGE['default'] = SqlStorage +PASSWORD_STORAGE['bitwarden'] = BitwardenStorage +PASSWORD_STORAGE['gnome'] = GnomeKeyringStorage diff --git a/barkshark_web/passwords/base.py b/barkshark_web/passwords/base.py new file mode 100644 index 0000000..a0fe7d0 --- /dev/null +++ b/barkshark_web/passwords/base.py @@ -0,0 +1,257 @@ +from izzylib.misc import class_name + +from . import PASSWORD_STORAGE + +from ..functions import TimeoutCallback, get_app, run_in_gui_thread + + +PASSWORD_KEYS = [ + 'id', + 'label', + 'username', + 'password', + 'domain', + 'url', + 'note', + 'created', + 'modified' +] + + +class PasswordResult: + def __init__(self, storage, items): + self._storage = storage + self._items = items + + + def __iter__(self): + for item in self._items: + yield self._storage.item_class(self._storage, item) + + + def all(self): + return tuple(item for item in self) + + + def one(self): + for item in self: + return item + + +class PasswordItem: + def __init__(self, storage, item): + self._storage = storage + self._data = item + + + def __repr__(self): + return f'{class_name(self)}("{self.label}", username="{self.username}", domain="{self.domain}")' + + + def __getitem__(self, key): + if key not in self.keys(): + raise KeyError(key) + + value = self._get_value(key) + return self.__default_parse(key, value) + + + def __setitem__(self, key, value): + if key in ['id', 'created', 'modified'] or key not in self.keys(): + raise KeyError(key) + + value = self.__default_parse(key, value) + self._set_value(key, value) + + + def __delitem__(self, key): + raise AttributeError('Cannot delete item values') + + + def __getattr__(self, key): + try: + return self.__getitem__(key) + + except KeyError: + return object.__getattribute__(self, key) + + + def __setattr__(self, key, value): + try: + self.__setitem__(key, value) + + except KeyError: + object.__setattr__(self, key, value) + + + def __default_parse(self, key, value): + if key == 'url' and not isinstance(value, (Url, type(None))): + return Url(value) + + elif key in ['created', 'modified'] and not isinstance(value, (DateString, type(None))): + return DateString.new_http(value) + + return self._parse_value(key, value) + + + def _parse_value(self, key, value): + return value + + + def _get_value(self, key): + return self._item[key] + + + def _set_value(self, key, value): + self._item[key] = value + + + def copy_password(self, timeout=60): + app = get_app() + app.set_clipboard_text(self.password) + + timer = TimeoutCallback(timeout, run_in_gui_thread, app.handle_clipboard_clear_password, self.password) + timer.start() + + + def copy_username(self): + get_app().set_clipboard_text(self.username) + + + def insert_row(self, storage=None): + if self.id: + raise RuntimeError('Row already has an ID and is probably in the DB') + + if storage: + return storage.insert(**self.to_dict()) + + return self._storage.insert(**self.to_dict()) + + + def update_row(self, **kwargs): + self.update(**kwargs) + self._storage.update(self.id, **kwargs) + + + def remove_row(self): + self._storage.remove(self.id) + + + def to_dict(self, indent=None): + return DotDict({key: self[key] for key in self.keys()}) + + + ## dict methods + def items(self): + for key in self.keys(): + yield (key, self[key]) + + + def keys(self): + return PASSWORD_KEYS.copy() + + + def values(self): + return tuple(self[key] for key in self.keys()) + + + def update(self, _data={}, **kwargs): + for key, value in [*_data.items(), *kwargs.items()]: + if key not in self.keys(): + raise KeyError(f'Invalid PasswordItem key: {key}') + + self[key] = value + + +class PasswordStorage: + result_class = PasswordResult + item_class = PasswordItem + + + @property + def app(self): + return get_app() + + + @property + def window(self): + return self.app.window + + + @property + def storage_type(self): + for key, value in PASSWORD_STORAGE.items(): + if self.__class__ == value: + return key + + + def start(self): + pass + + + def stop(self): + pass + + + def _fetch(self, **kwargs): + pass + + + def lock(self): + pass + + + def unlock(self, password=None): + pass + + + def create_row(self, *args, **kwargs): + return self.item_class.new(*args, **kwargs, storage=self) + + + def parse_data(self, username=None, domain=None, password=None, url=None, note=None, label=None, **kwargs): + kwargs.update( + username = username, + domain = domain, + url = url, + note = note, + password = password, + label = label + ) + + for key, value in tuple(kwargs.items()): + if not value: + del kwargs[key] + + elif key == 'url': + kwargs['url'] = Url(url) + + return kwargs + + + def fetch(self, label=None, username=None, domain=None, **kwargs): + if not any((name, username, domain)): + raise ValueError('Name, username, or domain not provided') + + if name: + kwargs['name'] = name + + if username: + kwargs['username'] = username + + if domain: + kwargs['domain'] = domain + + return self.result_class(self, self._fetch(**kwargs)) + + + def insert(self, name, username, domain, **kwargs): + pass + + + def update(self, row_id, **kwargs): + pass + + + def remove(self, row_id): + pass diff --git a/barkshark_web/passwords/bitwarden/__init__.py b/barkshark_web/passwords/bitwarden/__init__.py new file mode 100644 index 0000000..25a2f9f --- /dev/null +++ b/barkshark_web/passwords/bitwarden/__init__.py @@ -0,0 +1,3 @@ +from .item import BitwardenItem +from .result import BitwardenResult +from .storage import BitwardenStorage diff --git a/barkshark_web/passwords/bitwarden/item.py b/barkshark_web/passwords/bitwarden/item.py new file mode 100644 index 0000000..53001ec --- /dev/null +++ b/barkshark_web/passwords/bitwarden/item.py @@ -0,0 +1,200 @@ +from izzylib import logging +from izzylib.datestring import DateString +from izzylib.misc import convert_to_boolean + +from ..base import PasswordItem +from ...enums import * + + +class BitwardenItem(PasswordItem): + def __init__(self, storage, item): + PasswordItem.__init__(self, storage, DotDict(item)) + + + @classmethod + def new(cls, *args, **kwargs): + return cls.new_login(*args, **kwargs) + + @classmethod + def new_login(cls, username, password, url, label=None, note=None, storage=None, **kwargs): + if not isinstance(url, Url): + url = Url(url) + + data = DotDict( + type = BitwardenItemType.LOGIN, + organizationId = kwargs.get('organization_id'), + collectionIds = kwargs.get('collection_ids'), + name = label or url.domain, + notes = note, + favorite = convert_to_boolean(kwargs.get('favourite')), + reprompt = 0, + login = DotDict( + username = username, + password = password, + totp = None, + uris = [ + DotDict(match=BitwardenUrlMatchType.DOMAIN, uri=url) + ] + ), + fields = [ + DotDict( + name = 'created', + value = kwargs.get('created') or DateString.now('http'), + type = BitwardenFieldType.TEXT + ) + ] + ) + + return cls(storage, data) + + + def _get_value(self, key): + if key == 'id': + return self._data['id'] + + elif key == 'label': + return self._data['name'] + + elif key == 'url': + try: + return self._data['login']['uris'][0]['uri'] + + except KeyError: + return + + elif key == 'domain': + try: + return self.url.domain + + except AttributeError: + return None + + elif key == 'username': + return self._data['login']['username'] + + elif key == 'password': + return self._data['login']['password'] + + elif key == 'note': + return self._data['notes'] + + elif key == 'created': + return self.get_field('created') + + elif key == 'modified': + return self._data['revisionDate'] + + elif key == 'deleted': + return self._data['deletedDate'] + + + def _set_value(self, key, value): + if key == 'label': + self._data['name'] = value + + elif key == 'url': + try: + self._data['login']['uris'][0]['uri'] = value + + except (KeyError, IndexError): + self._data['login']['uris'] = [{'uri': value, 'match': BitwardenUrlMatchType.DOMAIN}] + + elif key == 'username': + try: + self._data['login']['username'] = value + + except KeyError: + self._data['login'] = {'username': value} + + elif key == 'password': + try: + self._data['login']['password'] = value + + except KeyError: + self._data['login'] = {'password': value} + + elif key == 'note': + self._data['notes'] = value + + elif key == 'created': + self.set_field(key, value) + + elif key == 'modified': + self._data['revisionDate'] = value + + elif key == 'deleted': + self._data['deletedDate'] = value + + elif key in ['id', 'domain']: + pass + + else: + raise KeyError(key) + + + @property + def trash(self): + return True if self.deleted else False + + + def compare(self, **kwargs): + for key, value in kwargs.items(): + item_value = self[key] + + if item_value != value and (isinstance(item_value, str) and value not in item_value): + #print(value, item_value, item_value != value, (isinstance(item_value, str) and value not in item_value)) + return False + + return True + + + def get_field(self, key, default=None): + try: + for item in self._data['fields']: + if item['name'].lower() == key.lower(): + return item['value'] + + except AttributeError: + pass + + return default + + + def set_field(self, key, value, type=BitwardenFieldType.TEXT): + if not isinstance(self._data['fields'], list): + self._data['fields'] = [] + + for item in self._data['fields']: + if item['name'].lower() == key.lower(): + item['value'] = value + item['type'] = type + + return + + self._data['fields'].append(dict( + name = key, + value = value, + type = type + )) + + + def generate_password(self, *args, **kwargs): + self._data.login.password = self._storage.generate_password(*args, **kwargs) + + + def generate_passphrase(self, *args, **kwargs): + self._data.login.password = self._storage.generate_passphrase(*args, **kwargs) + + + def purge(self): + self._storage.purge(self.id) + + + def to_json(self, indent=None): + return self._data.to_json(indent) + + + def update_row(self, **kwargs): + self.update(kwargs) + self._data = self._storage.send(f'object/item/{self.id}', 'PUT', self.to_json()) + diff --git a/barkshark_web/passwords/bitwarden/result.py b/barkshark_web/passwords/bitwarden/result.py new file mode 100644 index 0000000..fbf2ff8 --- /dev/null +++ b/barkshark_web/passwords/bitwarden/result.py @@ -0,0 +1,6 @@ +from ..base import PasswordResult + +class BitwardenResult(PasswordResult): + def __iter__(self): + for item in self._items: + yield item diff --git a/barkshark_web/passwords/bitwarden/storage.py b/barkshark_web/passwords/bitwarden/storage.py new file mode 100644 index 0000000..d9da230 --- /dev/null +++ b/barkshark_web/passwords/bitwarden/storage.py @@ -0,0 +1,439 @@ +import time + +from datetime import datetime, timedelta +from izzylib import logging +from izzylib.datestring import DateString +from izzylib.exceptions import HttpClientError +from izzylib.http_client import HttpClient +from izzylib.misc import convert_to_boolean, convert_to_string, random_port +from json.decoder import JSONDecodeError +from nodejs import node, npm + +from .item import BitwardenItem +from .result import BitwardenResult + +from ..base import PasswordItem, PasswordStorage + +from ... import scriptpath +from ...enums import * +from ...exceptions import BitwardenApiError, NoPasswordError + + +bwbin = scriptpath.join('bin/bw') +bwjs = scriptpath.parent.join('node_modules/@bitwarden/cli/build/bw.js') + + +def bool_str(value): + if convert_to_boolean(value): + return 'true' + + return 'false' + + +def check_deps(): + if not bwjs.exists(): + npm.run(['install', '@bitwarden/cli']) + + +class BitwardenStorage(PasswordStorage): + item_class = BitwardenItem + result_class = BitwardenResult + + + def __init__(self, host='localhost', port=None, external=False, session_key=None): + self._proc = None + self._account = None + self._current_port = None + self._client = HttpClient(headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }) + + self.host = host + self.port = port + self.external = external + self.session_key = session_key + + if not self.port: + if external: + self.port = 8087 + + else: + self.port = random_port(8000, 16000) + + check_deps() + + + def __del__(self): + self.stop() + + + @property + def api_active(self): + return self.external or (self._proc and self._proc.poll() == None) + + + @property + def unlocked(self): + return True if self.session_key else False + + + @property + def account(self): + if not self._account: + self._account = self.status().userEmail + + return self._account + + ## broken + #return self.status().status == 'unlocked' + + + def _process_response(self, raw_data, return_raw=False): + try: + data = DotDict(raw_data) + + if return_raw: + return data + + if 'success' not in data: + raise BitwardenApiError(data.message) + + try: + return data.data + + except AttributeError: + return data + + + except JSONDecodeError as e: + print(raw_data) + raise e from None + + + def get_port(self): + if self.port: + return self.port + + if not self._current_port: + if self.external: + self._current_port = 8087 + + else: + self._current_port = random_port(8000, 16000) + + return self._current_port + + + def create_row(self, *args, **kwargs): + return self.item_class.new(*args, **kwargs, storage=self) + + + ## send cli command + def execute(self, command, *args, data=None, key=True, return_raw=False): + #cmd = [bwbin, '--response'] + cmd = [bwjs, '--response'] + + if key: + if not self.session_key: + raise ValueError('Missing session key. Try calling login first.') + + cmd.append('--session') + cmd.append(self.session_key) + + proc_kwargs = { + 'capture_output': True, + 'encoding': 'utf-8' + } + + if data: + proc_kwargs['input'] = convert_to_string(data) + + result = node.run([*cmd, command, *args], **proc_kwargs) + return self._process_response(result.stdout, return_raw) + + + ## send api command + def send(self, api, method, data=None, return_raw=False): + if not self.api_active: + raise ConnectionError('API server is not running') + + kwargs = {'method': method} + + url = Url.new(self.host, + port = self.get_port(), + path = api, + proto = 'http' + ) + + if data and method == 'GET': + url = url.replace_property('query', data) + + with self._client.request(url, method, data, raise_error=True) as resp: + return self._process_response(resp.text, return_raw) + + + ## api server + def start(self, password=None): + if self.external or self.api_active: + return + + if password: + self.unlock(password) + + if not self.session_key: + raise ValueError('Missing session key') + + cmd = [bwjs, 'serve'] + cmd.extend(['--hostname', self.host]) + cmd.extend(['--port', str(self.port)]) + cmd.extend(['--session', self.session_key]) + + self._proc = node.Popen(cmd) + + while True: + try: + self.account + return + + except ConnectionRefusedError: + time.sleep(0.1) + + + def stop(self, lock=False): + if self.external or not self.api_active: + return + + self._proc.terminate() + wait_time = datetime.now() + + while self._proc.poll() == None: + if wait_time + timedelta(seconds=5) >= datetime.now(): + self._proc.kill() + break + + if lock: + self.lock() + + self._proc = None + self._account = None + self._current_port = None + + ## command methods + def login(self, email, password): + data = self.execute('login', email, password, key=False) + self.session_key = data.raw + return data.raw + + + def logout(self): + self.execute('logout') + self.session_key = None + + + def lock(self): + self.execute('lock') + self.session_key = None + + + def unlock(self, password): + data = self.execute('unlock', password) + self.session_key = data.raw + + return data.raw + + + ## api methods + def status(self): + if self.session_key: + data = self.send('status', 'GET') + + else: + data = self.execute('status', key=None) + + return data.template + + + def fetch(self, text=None, trash=False, **fields): + kwargs = {} + + if (row_id := fields.get('id')): + row = self.send(f'object/item/{row_id}', 'GET') + return self.item_class(self, row) + + if text: + kwargs['search'] = text + + if trash: + kwargs['trash'] = trash + + rows = [] + data = self.send('list/object/items', 'GET', kwargs) + + for row in data.data: + row = self.item_class(self, row) + + if row.compare(**fields): + rows.append(row) + + return self.result_class(self, rows) + + + def insert(self, *args, **kwargs): + self.insert_row(self.item_class.new(*args, **kwargs, storage=self)) + + + def insert_row(self, row): + data = self.execute('create', 'item', + self.execute('encode', data=row.to_json()).data + ) + + return self.item_class(self, data) + + + ## Causes the http server to return a 500 error + def insert_row_api(self, row): + data = self.send('object/item', 'POST', row._data) + return self.item_class(self, data) + + + def update(self, item_id, **kwargs): + row = self.fetch(id=item_id) + new_row = self.update_row(row, **kwargs) + + return new_row + + def update_row(self, row, **kwargs): + row.update(kwargs) + new_row = self.send(f'object/item/{row.id}', 'PUT', row.to_json()) + + return self.item_class(self, new_row) + + + def remove(self, item_id): + data = self.send(f'object/item/{item_id}', 'DELETE', return_raw=True) + + if not data.get('success'): + raise BitwardenApiError(data) + + return True + + + def purge(self, item_id): + data = self.execute('delete', 'item', item_id, '--permanent') + + if not data.get('success'): + raise BitwardenApiError(data) + + return True + + + def purge_all(self, dry_run=False): + rows = self.fetch(trash=True).all() + + for row in rows: + if not dry_run: + self.purge(row.id) + + return rows + + + def restore(self, item_id): + data = self.send(f'restore/item/{item_id}', 'POST', return_raw=True) + + if not data.get('success'): + raise BitwardenApiError(data) + + return True + + + def passgen(self, passphrase=False, **kwargs): + if passphrase: + if 'separator' in kwargs: + assert len(kwargs['separator']) == 1, 'Separator must be exactly 1 character' + + data = dict( + passphrase = 'true', + words = int(kwargs.get('words', 4)), + capitalize = bool_str(kwargs.get('capitalize')), + includeNumber = bool_str(kwargs.get('numbers')), + separator = kwargs.get('separator', '-') + ) + + else: + data = dict( + length = int(kwargs.get('length', 20)), + uppercase = bool_str(kwargs.get('uppercase', True)), + lowercase = bool_str(kwargs.get('lowercase', True)), + number = bool_str(kwargs.get('numbers', True)), + special = bool_str(kwargs.get('special', True)) + ) + + return self.send('generate', 'GET', data).data + + + def generate_passphrase(self, words=4, capitalize=False, numbers=False, separator='-'): + return self.passgen( + passphrase = True, + words = words, + capitalize = capitalize, + numbers = numbers, + separator = separator + ) + + + def generate_password(self, length=20, uppercase=True, lowercase=True, numbers=True, special=True): + return self.passgen( + passphrase = False, + length = length, + uppercase = uppercase, + lowercase = lowercase, + numbers = numbers, + special = special + ) + + + def template(self, name, api=False): + if api: + return self.send(f'template/{name}', 'GET').template + + return self.execute('get', 'template', name).template + + + def get_template(self, type='login'): + if type == 'login': + template = self.template('item') + template.login = self.template('item.login') + template.login.uris = [self.template('item.login.uri')] + template.fields = [self.template('item.field')] + + elif type == 'card': + template = self.template('item') + template.card = self.template('item.card') + template.fields = [] + + elif type == 'identity': + template = self.template('item') + template.identity = self.template('item.identity') + template.fields = [] + + elif type == 'note': + template = self.template('item') + template.securenote = self.template('item.securenote') + template.fields = [] + + elif type == 'folder': + template = self.template('folder') + + elif type == 'collection': + template = self.template('collection') + + else: + raise ValueError(f'Not a valid template: {type}') + + return template + + + def sync(self): + data = self.send('sync', 'POST', return_raw=True) + return data.success diff --git a/barkshark_web/passwords/gnome_keyring.py b/barkshark_web/passwords/gnome_keyring.py new file mode 100644 index 0000000..a2ad732 --- /dev/null +++ b/barkshark_web/passwords/gnome_keyring.py @@ -0,0 +1,311 @@ +import json, secretstorage + +from datetime import datetime +from secretstorage.collection import Collection, get_collection_by_alias, create_collection +from secretstorage.exceptions import ItemNotFoundException, LockedException + +from .base import PasswordItem, PasswordStorage, PasswordResult + +from ..functions import TimeoutCallback, get_app, run_in_gui_thread + + +pass_store_keys = ['username', 'domain', 'url', 'note'] +pass_keys = [*pass_store_keys, 'created', 'modified', 'label', 'password'] + + +def parse_data(self, username=None, domain=None, password=None, url=None, note=None, label=None, **kwargs): + kwargs.update( + username = username, + domain = domain, + url = url, + note = note, + password = password, + label = label + ) + + for key, value in tuple(kwargs.items()): + if not value: + del new_data[key] + + return kwargs + + +class GnomeKeyringStorage(PasswordStorage): + def __init__(self, name='BarksharkWeb'): + self.name = name + self.connection = None + self.collection = None + + + def __getitem__(self, key): + if (item := self.fetch(id=key).one()): + return item + + raise KeyError(f'No password with label: {key}') + + + def __setitem__(self, key, value): + if (item := self[key]): + item.update(**value) + + else: + self.insert(label=key, **value) + + + def __enter__(self): + self.connect() + return self + + + def __exit__(self, *args): + self.disconnect() + + + def connect(self): + if self.connection and self.collection: + return + + if not self.connection: + self.connection = secretstorage.dbus_init() + + if not secretstorage.check_service_availability(self.connection): + self.disconnect() + raise ConnectionError('Failed to connect to Secret Service server') + + return self.get_keyring() + + + def disconnect(self): + if self.connection: + self.connection.close() + + self.connection = None + self.collection = None + + + def get_keyring(self): + if not self.collection: + try: + self.collection = Collection(self.connection, f'/org/freedesktop/secrets/collection/{self.name}') + except ItemNotFoundException: + create_collection(self.connection, self.name) + self.collection = Collection(self.connection, f'/org/freedesktop/secrets/collection/{self.name}') + + try: + self.collection.ensure_not_locked() + except LockedException: + self.unlock() + + return self.collection + + + def unlock(self): + return self.collection.unlock() + + + def lock(self): + return self.collection.lock() + + + def fetch(self, *args, **kwargs): + data = parse_data(*args, **kwargs) + data.pop('password', None) + + if not any(data.values()): + rows = self.collection.get_all_items() + + else: + rows = self.collection.search_items(data) + + return PasswordResult(self, rows) + + + def insert(self, *args, label=None, **kwargs): + data = parse_data(*args, **kwargs) + data['id'] = random_str() + required = {key: data.get(key) for key in ['username', 'domain', 'password']} + + if None in required.values(): + raise ValueError(f'Forgot username, domain, or password: {json.dumps(required)}') + + username = data['username'] + password = data.pop('password') + + if (row := self.fetch(username, password).one()): + logging.verbose('Password already exists:', row.label) + return row + + if not label: + label = f'{username} @ {password}' + + row = self.collection.create_item(label, data, password.encode()) + return PasswordItem(row) + + + def update(self, username, domain, **kwargs): + row = self.fetch(username, domain).one() + row.update(**kwargs) + return row + + + def remove(self, *args, **kwargs): + for row in self.fetch(*args, **kwargs): + row.delete() + + +class GnomeKeyringItem(PasswordItem): + def __init__(self, item): + super().__init__() + + self._item = item + self._attrs = item.get_attributes() + + if not self._attrs.get('id'): + self.update({'id': random_str()}) + + + def __repr__(self): + return f'PasswordItem(id={self.id}, username={self.username}, domain={self.domain})' + + + def __getitem__(self, key): + if key not in pass_keys: + raise KeyError(key) + + return getattr(self, key) + + + def __setitem__(self, key, value): + if key not in pass_store_keys: + raise KeyError(key) + + self.update(**{key: value}) + + + @property + def created(self): + return datetime.fromtimestamp(self._item.get_created()) + + + @property + def modified(self): + return datetime.fromtimestamp(self._item.get_modified()) + + + @property + def label(self): + if not (label := self._item.get_label()): + return f'{self.username} @ {self.domain}' + + return label + + + @property + def id(self): + return self._attrs.get('id') + + + @property + def password(self): + return self._item.get_secret().decode() + + + @property + def username(self): + return self._attrs.get('username') + + + @property + def domain(self): + return self._attrs.get('domain') + + + @property + def url(self): + if (url := self._attrs.get('url')): + return Url(url) + + + @property + def note(self): + return self._attrs.get('note') + + + @password.setter + def password(self, value): + self.update(password=value) + + + @username.setter + def username(self, value): + self.update(username=value) + + + @domain.setter + def domain(self, value): + self.update(domain=value) + + + @url.setter + def url(self, value): + return self.update(url=str(value)) + + + @note.setter + def note(self, value): + return self.update(note=value) + + + def copy_password(self, timeout=60): + app = get_app() + app.set_clipboard_text(self.password) + + timer = TimeoutCallback(timeout, run_in_gui_thread, app.handle_clipboard_clear_password, self.password) + timer.start() + + + def copy_username(self): + get_app().set_clipboard_text(self.username) + + + def as_dict(self): + return DotDict( + id = self.id, + domain = self.domain, + username = self.username, + password = self.password, + url = self.url, + note = self.note, + created = self.created, + modified = self.modified + ) + + + def to_json(self, indent=None): + return self.as_dict().to_json(indent) + + + def update(self, data): + label = data.pop('label', None) + + if (password := data.pop('password', None)): + self._item.set_secret(password.encode()) + + if not len(data): + return + + new_data = parse_data(**self.as_dict()) + new_data.update(parse_data(**data)) + + self._item.set_attributes(new_data) + self._attrs = self._item.get_attributes() + + if label: + self._item.set_label(label) + + elif any(map(data.get, ['username', 'domain'])) and not self._item.get_label(): + self._item.set_label(self.label) + + + def delete(self): + self._item.delete() diff --git a/barkshark_web/passwords/sql.py b/barkshark_web/passwords/sql.py new file mode 100644 index 0000000..57ba683 --- /dev/null +++ b/barkshark_web/passwords/sql.py @@ -0,0 +1,182 @@ +from datetime import datetime + +from .base import PasswordItem, PasswordStorage + + +class SqlItem(PasswordItem): + def __init__(self, storage, row): + self._storage = storage + self._data = row + + + def __repr__(self): + return f'{class_name(self)}("{self.label}", username="{self.username}", domain="{self.domain}")' + + + def __getitem__(self, key): + if key not in self.keys(): + raise KeyError(key) + + value = self._get_value(key) + return self.__default_parse(key, value) + + + def __setitem__(self, key, value): + if key in ['id', 'created', 'modified', 'domain'] or key not in self.keys(): + raise KeyError(key) + + value = self.__default_parse(key, value) + self._set_value(key, value) + + + def __delitem__(self, key): + raise AttributeError('Cannot delete item values') + + + def __getattr__(self, key): + try: + return self.__getitem__(key) + + except KeyError: + return object.__getattribute__(self, key) + + + def __setattr__(self, key, value): + try: + self.__setitem__(key, value) + + except KeyError: + object.__setattr__(self, key, value) + + + def __default_parse(self, key, value): + if key == 'url' and not isinstance(value, (Url, type(None))): + return Url(value) + + elif key in ['created', 'modified'] and not isinstance(value, (DateString, type(None))): + return DateString.new_http(value) + + return self._parse_value(key, value) + + + def _parse_value(self, key, value): + return value + + + def _get_value(self, key): + return self.row[key] + + + def _set_value(self, key, value): + self.row[key] = value + + + def copy_password(self, timeout=60): + app = get_app() + app.set_clipboard_text(self.password) + + timer = TimeoutCallback(timeout, run_in_gui_thread, app.handle_clipboard_clear_password, self.password) + timer.start() + + + def copy_username(self): + get_app().set_clipboard_text(self.username) + + + def insert_row(self, storage=None): + if self.id: + raise RuntimeError('Row already has an ID and is probably in the DB') + + if storage: + return storage.insert(**self.to_dict()) + + return self._storage.insert(**self.to_dict()) + + + def update_row(self, **kwargs): + self.update(**kwargs) + self._storage.update(self.id, **kwargs) + + + def remove_row(self): + self._storage.remove(self.id) + + + def to_dict(self, indent=None): + return DotDict({key: self[key] for key in self.keys()}) + + + ## dict methods + def items(self): + for key in self.keys(): + yield (key, self[key]) + + + def keys(self): + return PASSWORD_KEYS.copy() + + + def values(self): + return tuple(self[key] for key in self.keys()) + + + def update(self, _data={}, **kwargs): + kwargs.update(data) + self.data.update(kwargs) + + +class SqlStorage(PasswordStorage): + item_class = SqlItem + + + def __init__(self, db): + self.db = db + + + def fetch(self, *args, deleted=None, **kwargs): + with self.db.session as s: + if (rowid := kwargs.get('id')): + if not (row := s.fetch('passwords', id=rowid).one()): + raise NoPasswordError(rowid) + + return row + + data = self.parse_data(*args, **kwargs) + return s.fetch('passwords', **data) + + + def insert(self, *args, **kwargs): + data = self.parse_data(*args, **kwargs) + data['created'] = datetime.now() + + if not data.get('domain') and data.get('url'): + data['domain'] = data['url'].hostname() + + with self.db.session as s: + s.insert('passwords', **data) + + return s.fetch('passwords', + username = data['username'], + password = data['password'], + domain = data['url'].domain if data.get('url') else None + ).one() + + + def update(self, row_id, *args, **kwargs): + data = self.parse_data(*args, **kwargs) + data['modified'] = datetime.now() + + if (url := data.get('domain')): + data['domain'] = url.hostname() + + with self.db.session as s: + s.update('passwords', data, id=row_id) + + return s.fetch('passwords', id=row_id).one() + + + def remove(self, row_id): + with self.db.session as s: + s.remove('passwords', id=row_id) + + return True diff --git a/barkshark_web/protocol/__init__.py b/barkshark_web/protocol/__init__.py index af16e45..0497528 100644 --- a/barkshark_web/protocol/__init__.py +++ b/barkshark_web/protocol/__init__.py @@ -8,6 +8,7 @@ from .handler import ( from .file import File from .ftp import Ftp from .local import Local +from .local_wasm import LocalWeb from .oauth import Oauth from .sftp import Sftp from .source import Source diff --git a/barkshark_web/protocol/handler.py b/barkshark_web/protocol/handler.py index 42940ea..4a8e07d 100644 --- a/barkshark_web/protocol/handler.py +++ b/barkshark_web/protocol/handler.py @@ -60,32 +60,36 @@ def handle_remote_file(request, data): return request.error(f'Unhandled mimetype: {mimetype}', 400) -class ProtocolRequest: +class ProtocolRequest(ObjectBase): def __init__(self, handler, request): - self._handler = handler - self._request = request - self._finished = False - self._finished_thread = False + ObjectBase.__init__(self, + handler = handler, + request = request, + ctx = DotDict(), + finished = False, + finished_thread = False, + readonly_props = ['handler', 'request', 'ctx'] + ) @property def app(self): - return self._handler.app + return self.handler.app @property def db(self): - return self._handler.app.db + return self.handler.app.db @property def window(self): - return self._handler.window + return self.handler.window @property def webview(self): - return self._request.get_web_view() + return self.request.get_web_view() @property @@ -93,14 +97,9 @@ class ProtocolRequest: return self.webview.parent - @property - def finished(self): - return self._finished - - @property def url(self): - return Url(self._request.get_uri()) + return Url(self.request.get_uri()) @property @@ -150,12 +149,12 @@ class ProtocolRequest: data = data.encode('UTF-8') bytestream = Gio.MemoryInputStream.new_from_bytes(GLib.Bytes(data)) - self._request.finish(bytestream, len(data), ctype) - self._finished = True + self.request.finish(bytestream, len(data), ctype) + self.finished = True def finish_thread(self, func, *args, **kwargs): - self._finished_thread = True + self.finished_thread = True Thread(target=func, args=args, kwargs=kwargs).start() @@ -164,8 +163,8 @@ class ProtocolRequest: return logging.error('Already responded to request:', self.url) error = GLib.Error.new_literal(GLib.quark_from_string('HandlerError'), body, status) - self._request.finish_error(error) - self._finished = True + self.request.finish_error(error) + self.finished = True def page(self, page, context={}): @@ -199,7 +198,7 @@ class ProtocolRequest: self.window.notification(message, level) if url and url.startswith('/'): - url = self._handler.protocol + '://' + url + url = self.handler.protocol + '://' + url if not self.finished: self.response('OK') @@ -213,6 +212,7 @@ class ProtocolHandler(ObjectBase): protocol = protocol, router = http_router.Router(), request_class = request_class, + ctx = DotDict(), readonly_props = True ) @@ -237,7 +237,7 @@ class ProtocolHandler(ObjectBase): else: response = route.target(self, request, **(route.params or {})) - if request._finished_thread: + if request.finished_thread: return if not request.finished: diff --git a/barkshark_web/protocol/local.py b/barkshark_web/protocol/local.py index e7783d0..c9fe5b5 100644 --- a/barkshark_web/protocol/local.py +++ b/barkshark_web/protocol/local.py @@ -9,6 +9,7 @@ from .functions import error, finish_request, finish_request_error, redirect from .. import var from ..functions import TimeoutCallback, get_app, run_in_gui_thread +from ..passwords import PASSWORD_STORAGE class LocalRequest(ProtocolRequest): @@ -293,22 +294,21 @@ def history_clear(handler, request): ### Passwords ### @Local.route('/passwords') -def passwords_home(handler, request): +def passwords_home(handler,request): domain = request.query.get('domain') - with handler.db.session as s: - if (search_text := request.query.get('text')): - items = [] + if (search_text := request.query.get('text')): + items = [] - for row in s.fetch('passwords', domain=domain): - if True in fuzzy_string_match(search_text, row.domain, row.url, row.username, row.note, row.label): - items.append(row) + for row in handler.app.password.fetch(): + if True in fuzzy_string_match(search_text, row.domain, row.url, row.username, row.note, row.label): + items.append(row) - elif domain: - items = s.fetch('passwords', domain=domain).all() + elif domain: + items = handler.app.password.fetch(domain=domain).all() - else: - items = s.fetch('passwords').all() + else: + items = handler.app.password.fetch().all() return request.page('passwords', {'items': items}) @@ -318,11 +318,10 @@ def passwords_edit(handler, request, rowid=None): if not rowid: return request.page('password-edit', {'password': None}) - with handler.db.session as s: - if not (item := s.fetch('passwords', id=rowid).one()): - return request.error(f'Cannot find password: {rowid}', 404) + if not (row := handler.app.password.fetch(id=rowid)): + return request.error(f'Cannot find password with row ID: {rowid}', 404) - return request.page('password-edit', {'password': item}) + return request.page('password-edit', {'password': row}) @Local.route('/passwords/update', '/passwords/update/{rowid}') @@ -330,35 +329,29 @@ def passwords_update(handler, request, rowid=None): query = request.query.copy() query.pop('redir', None) - print(query.get('label')) - if not query.get('label'): query['label'] = f'{query.username} @ {query.domain}' - print(query.get('label')) - for key, value in list(query.items()): if isinstance(value, str) and not value: del query[key] - with handler.db.session as s: - if not rowid: - row = s.put_password(**query) - return request.ok_or_redirect(f'Created new password: {row.label}') + if not rowid: + row = handler.app.password.insert(**query) + return request.ok_or_redirect(f'Created new password: {row.label}') - if not (row := s.fetch('passwords', id=rowid).one()): - return request.error(f'Cannot find password: {rowid}', 404) + if not all(map(query.get, ('username', 'password', 'url'))): + return request.error(f'Missing username, password or url', 400) - row = s.put_password_row(row, **query) - - return request.ok_or_redirect(f'updated password: {row.label}') + row = handler.app.password.update(rowid, **query) + return request.ok_or_redirect(f'Updated password: {row.label}') @Local.route('/passwords/copy/{rowid}') def passwords_copy(handler, request, rowid): - with handler.db.session as s: - if not (row := s.fetch('passwords', id=rowid).one()): - return request.error(f'Cannot find password: {rowid}', 404) + + if not (row := handler.app.password.fetch(id=rowid)): + return request.error(f'Cannot find password with ID: {rowid}', 404) row.copy_password() return request.ok_or_redirect(f'Copied password for 60 seconds') @@ -366,19 +359,20 @@ def passwords_copy(handler, request, rowid): @Local.route('/passwords/delete/{rowid}') def passwords_update(handler, request, rowid): - with handler.db.session as s: - try: - s.remove('passwords', id=rowid) - return request.ok_or_redirect(f'Deleted password: {label}') + if not (row := handler.app.password.fetch(id=rowid)): + return request.error(f'Cannot find password with ID: {rowid}', 404) - except KeyError: - return request.error(f'Cannot find password: {rowid}', 404) + handler.app.password.remove(rowid) + return request.ok_or_redirect(f'Deleted password: {row.label}') + + #return request.error(f'Cannot find password: {rowid}', 404) ### Preferences ### @Local.route('/preferences') def preferences_home(handler, request): context = DotDict( + pass_backends = list(PASSWORD_STORAGE.keys()), settings = { 'font': 'sans undertale', 'font-size': '14' @@ -398,6 +392,9 @@ def preferences_update(handler, request): if key == 'redir': continue + elif key == 'pass_type': + handler.app.setup_password_storage(value) + row = s.put_config(key, value) logging.verbose(f'Updated config: {row.key} = {row.value}') diff --git a/barkshark_web/protocol/local_wasm.py b/barkshark_web/protocol/local_wasm.py new file mode 100644 index 0000000..8942b33 --- /dev/null +++ b/barkshark_web/protocol/local_wasm.py @@ -0,0 +1,132 @@ +import objgraph, mimetypes + +from functools import partial +from jinja2.exceptions import TemplateNotFound +from izzylib import Color, class_name, fuzzy_string_match +from izzylib_http_async import Template +from threading import Thread +from urllib.parse import quote + +from . import ProtocolHandler, ProtocolRequest, handle_remote_file, list_directory +from .functions import error, finish_request, finish_request_error, redirect + +from .. import var, scriptpath +from .. import __version__ as version, __software__ as swname +from ..functions import TimeoutCallback, get_app, run_in_gui_thread +from ..passwords import PASSWORD_STORAGE + + +class LocalWebRequest(ProtocolRequest): + def _render_template(self, path, context={}): + try: + return self._handler.ctx.template.render(path, context) + + except TemplateNotFound: + logging.error('Cannot find template:', path) + return self._handler.ctx.template.render('error.haml', {'title': 'Template Not Found', 'error_msg': path}) + + + def ok_or_redirect(self, *message, level='INFO'): + if redir := self.query.get('redir'): + return self.redirect(redir, ' '.join(message), level) + + self.window.notification(' '.join(message), level=level) + return self.response('ok') + + + def handle_refresh_account(self, row): + row.refresh() + self.ok_or_redirect(f'Refreshed account info: {row.fullhandle}') + + + def handle_return_account(self, row): + html = self._render_template('account_row.haml', {'row': row}) + return self.response(html) + + +def handle_template_context(handler, context): + return context + + +LocalWeb = ProtocolHandler('bsweb', LocalWebRequest) +LocalWeb.ctx.template = Template( + autoescape = False, + context = partial(handle_template_context, LocalWeb), + search = [ + scriptpath.join('localweb2'), + scriptpath.join('resources') + ] +) + +LocalWeb.ctx.template.update_env({ + 'len': len, + 'str': str, + 'app': LocalWeb.app, + 'var': var, + 'version': version, + 'swname': swname, + 'quote': quote +}) + + +### Home ### +@LocalWeb.route('/') +def home(handler, request): + ctx = dict( + page = 'Merp!' + ) + + try: + return request.template('base.haml', ctx, 'text/html') + #return request.response('UvU') + + except Exception as e: + print(class_name(e), e) + raise e from None + + +### Resources ### +@LocalWeb.route('/css/{filename}') +def css(handler, request, filename): + context = DotDict( + primary = Color('#e7a'), + secondary = Color('#e7a'), + background = Color('#191919'), + positive = Color('#ada'), + negative = Color('#daa'), + speed = 350 + ) + + return request.template(f'css/{filename}', context) + + +@LocalWeb.route('/js/{filename}') +def javascript(handler, request, filename): + with handler.app.path.script.join('localweb2', 'js', filename).open() as fd: + return request.response(fd.read(), 'application/javascript') + + +@LocalWeb.route('/py/{filename}') +def python_module(handler, request, filename): + with handler.app.path.script.join('localweb2', 'py', filename).open() as fd: + return request.response(fd.read(), 'text/python') + + +@LocalWeb.route('/font/{name}/{filename}') +def font(handler, request, name, filename): + with handler.app.path.localweb.join('fonts', name, filename).open('rb') as fd: + return request.response(fd.read(), request.mimetype) + + +@LocalWeb.route('/icon/{name}') +def icon(handler, request, name): + if name == 'app.png': + path = handler.app.path.resources.join('icon.png') + mime = 'image/png' + + else: + path = handler.app.path.resources.join('icons', name) + mime = 'image/svg+xml' + + with path.open('rb') as fd: + return request.response(fd.read(), mime) diff --git a/barkshark_web/resources/main.ui b/barkshark_web/resources/main.ui index 2ec8942..3b12034 100644 --- a/barkshark_web/resources/main.ui +++ b/barkshark_web/resources/main.ui @@ -2,6 +2,37 @@ + + False + dialog + + + False + vertical + 2 + + + False + end + + + + + + + + + False + False + 0 + + + + + + + + True False @@ -198,6 +229,149 @@ False window-close + + False + Password Storage Login + dialog + + + False + vertical + 2 + + + False + end + + + Cancel + True + True + True + + + True + True + 0 + + + + + Reset + True + True + True + + + True + True + 1 + + + + + Login + True + True + True + + + True + True + 2 + + + + + False + False + 0 + + + + + + True + False + 5 + 5 + + + True + False + Password + + + 0 + 3 + + + + + True + True + True + + + 1 + 3 + + + + + True + True + True + + + 1 + 2 + + + + + True + False + E-Mail + + + 0 + 2 + + + + + True + False + + + 0 + 1 + 2 + + + + + True + False + Login to your Bitwarden account + + + 0 + 0 + 2 + + + + + True + True + 1 + + + + + 1000 100 diff --git a/pyvenv.json b/pyvenv.json index a4d814b..8334876 100644 --- a/pyvenv.json +++ b/pyvenv.json @@ -46,6 +46,11 @@ "options": [], "url": null }, + "nodejs-bin": { + "version": "18.4.0a4", + "options": [], + "url": null + }, "objgraph": { "version": "3.5.0", "options": [], @@ -70,6 +75,11 @@ "version": "0.2.9", "options": [], "url": null + }, + "secretstorage": { + "version": "3.3.2", + "options": [], + "url": null } }, "watcher": { @@ -88,7 +98,9 @@ "ignore_dirs": [ "build", "config", - "data" + "data", + "localweb", + "localweb2" ], "ignore_files": [ "reload.py", diff --git a/requirements.txt b/requirements.txt index 859c4cf..be44fd0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,8 +7,10 @@ izzylib-http-async@git+https://git.barkshark.xyz/izaliamae/izzylib-http-async izzylib-sql@git+https://git.barkshark.xyz/izaliamae/izzylib-sql lxml==4.6.3 mastodon.py==1.5.1 +nodejs-bin==18.4.0a4 objgraph==3.5.0 pillow==8.3.2 psutil==5.8.0 pygobject==3.38.0 pysftp==0.2.9 +secretstorage==3.3.2 diff --git a/setup.cfg b/setup.cfg index 317178c..45545aa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,20 +33,22 @@ python_requires = >= 3.8 packages = barkshark_web setup_requires = - izzylib @ git+https://git.barkshark.xyz/izaliamae/izzylib - izzylib_sql @ git+https://git.barkshark.xyz/izaliamae/izzylib-sql - izzylib_http_async @ git+https://git.barkshark.xyz/izaliamae/izzylib-http-async beautifulsoup4==4.9.3 click==8.1.0 dasbus==1.6 http-router==2.6.5 + izzylib@git+https://git.barkshark.xyz/izaliamae/izzylib + izzylib-http-async@git+https://git.barkshark.xyz/izaliamae/izzylib-http-async + izzylib-sql@git+https://git.barkshark.xyz/izaliamae/izzylib-sql lxml==4.6.3 mastodon.py==1.5.1 + nodejs-bin==18.4.0a4 objgraph==3.5.0 pillow==8.3.2 psutil==5.8.0 pygobject==3.38.0 pysftp==0.2.9 + secretstorage==3.3.2 [options.entry_points] console_scripts =