add support for multiple password backends

This commit is contained in:
Izalia Mae 2022-08-10 18:02:20 -04:00
parent 3dd8f17c71
commit ffa4d71ce2
30 changed files with 2005 additions and 141 deletions

View file

@ -18,6 +18,7 @@ clean:
find . -name '*.pyc' -exec rm --force {} + find . -name '*.pyc' -exec rm --force {} +
find . -name '*.pyo' -exec rm --force {} + find . -name '*.pyo' -exec rm --force {} +
rm --recursive --force $(VENV) rm --recursive --force $(VENV)
rm barkshark_web/bin/bw
build-ext: build-ext:
mkdir --parent $(SRCNAME)/bin mkdir --parent $(SRCNAME)/bin
@ -28,6 +29,12 @@ clean-ext:
rm --force $(PY_LOADER) rm --force $(PY_LOADER)
rm --recursive --force webextension/build 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: aptdeps:
sudo apt install `cat apt-requirements.txt | xargs` --no-install-recommends -y sudo apt install `cat apt-requirements.txt | xargs` --no-install-recommends -y

View file

@ -42,6 +42,7 @@ izzylib.add_builtins(
get_app = lambda : Gio.Application.get_default() get_app = lambda : Gio.Application.get_default()
) )
scriptpath = Path(__file__).resolve().parent
proto = 'pyweb' proto = 'pyweb'
var = ObjectBase( var = ObjectBase(
readonly_props = True, readonly_props = True,

View file

@ -9,13 +9,14 @@ from urllib.parse import quote
from .web_context import WebContext from .web_context import WebContext
from .window import Window from .window import Window
from .. import dbus, var from .. import dbus, scriptpath, var
from .. import __version__ as version, __software__ as swname from .. import __version__ as version, __software__ as swname
from ..cookies import get_cookie_db from ..cookies import get_cookie_db
from ..database import get_database from ..database import get_database
from ..enums import LibraryPage from ..enums import LibraryPage
from ..exceptions import AccountNotFoundError, NoAccountsError from ..exceptions import AccountNotFoundError, NoAccountsError
from ..functions import connect from ..functions import connect
from ..passwords import PASSWORD_STORAGE
class Application(Gtk.Application): class Application(Gtk.Application):
@ -35,6 +36,7 @@ class Application(Gtk.Application):
self.cookies = None self.cookies = None
self.context = None self.context = None
self.window = None self.window = None
self.password = None
self.setup(profile) self.setup(profile)
@ -45,7 +47,6 @@ class Application(Gtk.Application):
def setup(self, profile): def setup(self, profile):
script = Path(__file__).parent.parent
data = Path.app_data_dir('config', 'barkshark', 'web') data = Path.app_data_dir('config', 'barkshark', 'web')
profilepath = data.join(profile or 'DEFAULT') profilepath = data.join(profile or 'DEFAULT')
@ -55,11 +56,11 @@ class Application(Gtk.Application):
# misc paths # misc paths
downloads = Path('~/Downloads'), downloads = Path('~/Downloads'),
data = data, data = data,
script = script, script = scriptpath,
# browser paths # browser paths
localweb = script.join('localweb'), localweb = scriptpath.join('localweb'),
resources = script.join('resources'), resources = scriptpath.join('resources'),
# data paths # data paths
profile = profilepath, 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): def get_account_by_handle(self, username, domain=None):
if not len(self.accounts): if not len(self.accounts):
raise NoAccountsError('No accounts') raise NoAccountsError('No accounts')
@ -186,6 +220,8 @@ class Application(Gtk.Application):
self.add_window(self.window) self.add_window(self.window)
self.setup_password_storage()
with self.db.session as s: with self.db.session as s:
self.accounts = s.fetch('accounts').all() self.accounts = s.fetch('accounts').all()

View file

@ -34,11 +34,13 @@ class WebContext(WebKit2.WebContext):
## Register local uri schemes ## Register local uri schemes
self.security_manager = self.get_security_manager() 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(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_local('local://') #Renamed "file://" handler
self.security_manager.register_uri_scheme_as_secure('oauth://') self.security_manager.register_uri_scheme_as_secure('oauth://')
## Setup custom protocols ## Setup custom protocols
self.register_uri_scheme(var.local_proto, protocol.Local) 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('sftp', protocol.Sftp)
self.register_uri_scheme('source', protocol.Source) self.register_uri_scheme('source', protocol.Source)
self.register_uri_scheme(protocol.Oauth.protocol, protocol.Oauth) self.register_uri_scheme(protocol.Oauth.protocol, protocol.Oauth)

View file

@ -328,7 +328,7 @@ class WebTab(BuilderBase, Gtk.Box):
def set_url(self, url=None): def set_url(self, url=None):
self['navbar-url'].set_text( self['navbar-url'].set_text(
url or self.webview.get_uri() url or self.webview.get_uri() or ''
) )

View file

@ -393,7 +393,7 @@ class WebviewHandler(ComponentBase):
if error.domain == 'WebKitPolicyError' and 'interrupted' in error.message.lower(): if error.domain == 'WebKitPolicyError' and 'interrupted' in error.message.lower():
if not url.mimetype: 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): elif not self.webview.can_show_mime_type(url.mimetype):
self.app.context.download_uri(url) self.app.context.download_uri(url)

View file

@ -36,6 +36,7 @@ default_config = {
'load_tabs': (True, 'bool'), 'load_tabs': (True, 'bool'),
'local_underline_links': (False, 'bool'), 'local_underline_links': (False, 'bool'),
'maximized': (True, 'bool'), 'maximized': (True, 'bool'),
'pass_type': ('bitwarden', 'str'),
'post_check': (False, 'bool'), 'post_check': (False, 'bool'),
'search': ('ddg', 'str'), 'search': ('ddg', 'str'),
'scale': (1.0, 'float'), 'scale': (1.0, 'float'),
@ -43,7 +44,13 @@ default_config = {
'location': (DotDict({'x': None, 'y': None}), 'dict'), 'location': (DotDict({'x': None, 'y': None}), 'dict'),
'tab_after_current': (True, 'bool'), 'tab_after_current': (True, 'bool'),
'tab_side': ('TOP', 'str'), '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 = { default_permissions = {
@ -176,7 +183,6 @@ tables = {
Column('username', 'text', nullable=False), Column('username', 'text', nullable=False),
Column('password', 'text', nullable=False), Column('password', 'text', nullable=False),
Column('label', 'text', nullable=False), Column('label', 'text', nullable=False),
Column('domain', 'text', nullable=False),
Column('url', 'text'), Column('url', 'text'),
Column('note', 'text'), Column('note', 'text'),
Column('created', 'datetime', nullable=False), Column('created', 'datetime', nullable=False),
@ -294,10 +300,6 @@ class CustomSession(Session):
return self.get_cached('history', url, url=url) 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): def get_permission(self, domain, cache=True):
row = self.get_cached('siteoptions', domain, domain = domain) row = self.get_cached('siteoptions', domain, domain = domain)
@ -454,59 +456,7 @@ class CustomSession(Session):
def put_history_from_tab(self, tab): def put_history_from_tab(self, tab):
return self.put_history(tab.url, tab.title, (True if tab.fedi_post else False)) return self.put_history(tab.url, tab.title, (True if tab._data.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)
def put_permission(self, domain, key, value): def put_permission(self, domain, key, value):
@ -578,10 +528,6 @@ class CustomSession(Session):
return rows_affected return rows_affected
def del_passfield(self, domain):
self.del_cached('passfields', domain, domain=domain)
def del_permission(self, domain): def del_permission(self, domain):
self.del_cached('siteoptions', domain, domain=domain) self.del_cached('siteoptions', domain, domain=domain)

View file

@ -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): class EditAction(Enum):
COPY = WebKit2.EDITING_COMMAND_COPY COPY = WebKit2.EDITING_COMMAND_COPY
LINK = WebKit2.EDITING_COMMAND_CREATE_LINK LINK = WebKit2.EDITING_COMMAND_CREATE_LINK
@ -13,6 +84,7 @@ class EditAction(Enum):
UNDO = WebKit2.EDITING_COMMAND_UNDO UNDO = WebKit2.EDITING_COMMAND_UNDO
@register
class Javascript(Enum): class Javascript(Enum):
SELECTION = 'window.getSelection().toString()' SELECTION = 'window.getSelection().toString()'
EXEC = 'window.execCommand("{}")' EXEC = 'window.execCommand("{}")'
@ -21,6 +93,7 @@ class Javascript(Enum):
DELETE = 'document.activeElement.setRangeText("")' DELETE = 'document.activeElement.setRangeText("")'
@register
class LibraryPage(Enum): class LibraryPage(Enum):
HOME = '' HOME = ''
BOOKMARKS = 'bookmarks' BOOKMARKS = 'bookmarks'
@ -34,6 +107,7 @@ class LibraryPage(Enum):
HELP = 'help' HELP = 'help'
@register
class WebviewContextActions(Enum): class WebviewContextActions(Enum):
AUDIO_COPY = WebKit2.ContextMenuAction.COPY_AUDIO_LINK_TO_CLIPBOARD AUDIO_COPY = WebKit2.ContextMenuAction.COPY_AUDIO_LINK_TO_CLIPBOARD
AUDIO_DOWNLOAD = WebKit2.ContextMenuAction.DOWNLOAD_AUDIO_TO_DISK AUDIO_DOWNLOAD = WebKit2.ContextMenuAction.DOWNLOAD_AUDIO_TO_DISK

View file

@ -3,3 +3,13 @@ class NoAccountsError(Exception):
class AccountNotFoundError(Exception): class AccountNotFoundError(Exception):
'Raise when a specific account is not found' '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

View file

@ -1,10 +1,18 @@
// General // General
function connect_event(name, signal, callback) { function connect_event(name, signal, callback) {
const element = document.getElementById(name); var element = document.getElementById(name);
element.addEventListener(signal, callback); 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) { function delete_item(base_url, id) {
request(`${base_url}/${id}`, (response, body) => { request(`${base_url}/${id}`, (response, body) => {
if (response.status != 200) {return;} if (response.status != 200) {return;}
@ -86,6 +94,8 @@ function handle_save_config(event) {
request(url, (response, body) => { request(url, (response, body) => {
if (response.status == 200) { if (response.status == 200) {
console.log(`Set config: ${input.id}=${value}`); console.log(`Set config: ${input.id}=${value}`);
} else {
console.log(`Failed to set config: ${input.id}`)
} }
}); });
} }

View file

@ -20,6 +20,29 @@
connect_event('{{key}}', 'change', handle_save_config); 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): -macro menu_item(label, path, icon):
%a.menu-item.grid-container href='{{var.local}}{{path}}' %a.menu-item.grid-container href='{{var.local}}{{path}}'
%img.image.grid-item src='{{var.local}}/icon/{{icon}}' title='{{label}}' %img.image.grid-item src='{{var.local}}/icon/{{icon}}' title='{{label}}'

View file

@ -24,9 +24,6 @@
%label.grid-item for='password' << Password %label.grid-item for='password' << Password
%input#password.grid-item id='password' class='full-width' type='password' name='password' placeholder='password' value='{{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 %label.grid-item for='url' << Url
%input.grid-item id='url' class='full-width' type='text' name='url' placeholder='url' value='{{password.url or ""}}' %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 %a.button onclick='javascript:document.getElementsByTagName("form")[0].submit();' << Save
-if password.id -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 %a.button onclick='window.location.href="{{var.local}}/passwords"' << Cancel

View file

@ -5,7 +5,7 @@
%link rel='stylesheet' type='text/css' href='{{var.local}}/css/passwords.css' %link rel='stylesheet' type='text/css' href='{{var.local}}/css/passwords.css'
-block content -block content
%form.buttons %form#search-form.buttons
%a.button onclick='toggle_all_details("item", true)' << Open %a.button onclick='toggle_all_details("item", true)' << Open
%a.button onclick='toggle_all_details("item", false)' << Close %a.button onclick='toggle_all_details("item", false)' << Close
%a.button onclick='window.location.href="{{var.local}}/passwords/edit"' << New %a.button onclick='window.location.href="{{var.local}}/passwords/edit"' << New

View file

@ -1,6 +1,6 @@
-set page = 'Preferences' -set page = 'Preferences'
-extends 'base.haml' -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 -block meta
%link rel='stylesheet' type='text/css' href='{{var.local}}/css/preferences.css' %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 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) %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 %details.category.section open
%summary << Advanced %summary << Advanced
.container .container

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
from .item import BitwardenItem
from .result import BitwardenResult
from .storage import BitwardenStorage

View file

@ -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())

View file

@ -0,0 +1,6 @@
from ..base import PasswordResult
class BitwardenResult(PasswordResult):
def __iter__(self):
for item in self._items:
yield item

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -8,6 +8,7 @@ from .handler import (
from .file import File from .file import File
from .ftp import Ftp from .ftp import Ftp
from .local import Local from .local import Local
from .local_wasm import LocalWeb
from .oauth import Oauth from .oauth import Oauth
from .sftp import Sftp from .sftp import Sftp
from .source import Source from .source import Source

View file

@ -60,32 +60,36 @@ def handle_remote_file(request, data):
return request.error(f'Unhandled mimetype: {mimetype}', 400) return request.error(f'Unhandled mimetype: {mimetype}', 400)
class ProtocolRequest: class ProtocolRequest(ObjectBase):
def __init__(self, handler, request): def __init__(self, handler, request):
self._handler = handler ObjectBase.__init__(self,
self._request = request handler = handler,
self._finished = False request = request,
self._finished_thread = False ctx = DotDict(),
finished = False,
finished_thread = False,
readonly_props = ['handler', 'request', 'ctx']
)
@property @property
def app(self): def app(self):
return self._handler.app return self.handler.app
@property @property
def db(self): def db(self):
return self._handler.app.db return self.handler.app.db
@property @property
def window(self): def window(self):
return self._handler.window return self.handler.window
@property @property
def webview(self): def webview(self):
return self._request.get_web_view() return self.request.get_web_view()
@property @property
@ -93,14 +97,9 @@ class ProtocolRequest:
return self.webview.parent return self.webview.parent
@property
def finished(self):
return self._finished
@property @property
def url(self): def url(self):
return Url(self._request.get_uri()) return Url(self.request.get_uri())
@property @property
@ -150,12 +149,12 @@ class ProtocolRequest:
data = data.encode('UTF-8') data = data.encode('UTF-8')
bytestream = Gio.MemoryInputStream.new_from_bytes(GLib.Bytes(data)) bytestream = Gio.MemoryInputStream.new_from_bytes(GLib.Bytes(data))
self._request.finish(bytestream, len(data), ctype) self.request.finish(bytestream, len(data), ctype)
self._finished = True self.finished = True
def finish_thread(self, func, *args, **kwargs): def finish_thread(self, func, *args, **kwargs):
self._finished_thread = True self.finished_thread = True
Thread(target=func, args=args, kwargs=kwargs).start() Thread(target=func, args=args, kwargs=kwargs).start()
@ -164,8 +163,8 @@ class ProtocolRequest:
return logging.error('Already responded to request:', self.url) return logging.error('Already responded to request:', self.url)
error = GLib.Error.new_literal(GLib.quark_from_string('HandlerError'), body, status) error = GLib.Error.new_literal(GLib.quark_from_string('HandlerError'), body, status)
self._request.finish_error(error) self.request.finish_error(error)
self._finished = True self.finished = True
def page(self, page, context={}): def page(self, page, context={}):
@ -199,7 +198,7 @@ class ProtocolRequest:
self.window.notification(message, level) self.window.notification(message, level)
if url and url.startswith('/'): if url and url.startswith('/'):
url = self._handler.protocol + '://' + url url = self.handler.protocol + '://' + url
if not self.finished: if not self.finished:
self.response('OK') self.response('OK')
@ -213,6 +212,7 @@ class ProtocolHandler(ObjectBase):
protocol = protocol, protocol = protocol,
router = http_router.Router(), router = http_router.Router(),
request_class = request_class, request_class = request_class,
ctx = DotDict(),
readonly_props = True readonly_props = True
) )
@ -237,7 +237,7 @@ class ProtocolHandler(ObjectBase):
else: else:
response = route.target(self, request, **(route.params or {})) response = route.target(self, request, **(route.params or {}))
if request._finished_thread: if request.finished_thread:
return return
if not request.finished: if not request.finished:

View file

@ -9,6 +9,7 @@ from .functions import error, finish_request, finish_request_error, redirect
from .. import var from .. import var
from ..functions import TimeoutCallback, get_app, run_in_gui_thread from ..functions import TimeoutCallback, get_app, run_in_gui_thread
from ..passwords import PASSWORD_STORAGE
class LocalRequest(ProtocolRequest): class LocalRequest(ProtocolRequest):
@ -293,22 +294,21 @@ def history_clear(handler, request):
### Passwords ### ### Passwords ###
@Local.route('/passwords') @Local.route('/passwords')
def passwords_home(handler, request): def passwords_home(handler,request):
domain = request.query.get('domain') domain = request.query.get('domain')
with handler.db.session as s: if (search_text := request.query.get('text')):
if (search_text := request.query.get('text')): items = []
items = []
for row in s.fetch('passwords', domain=domain): 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): if True in fuzzy_string_match(search_text, row.domain, row.url, row.username, row.note, row.label):
items.append(row) items.append(row)
elif domain: elif domain:
items = s.fetch('passwords', domain=domain).all() items = handler.app.password.fetch(domain=domain).all()
else: else:
items = s.fetch('passwords').all() items = handler.app.password.fetch().all()
return request.page('passwords', {'items': items}) return request.page('passwords', {'items': items})
@ -318,11 +318,10 @@ def passwords_edit(handler, request, rowid=None):
if not rowid: if not rowid:
return request.page('password-edit', {'password': None}) return request.page('password-edit', {'password': None})
with handler.db.session as s: if not (row := handler.app.password.fetch(id=rowid)):
if not (item := s.fetch('passwords', id=rowid).one()): return request.error(f'Cannot find password with row ID: {rowid}', 404)
return request.error(f'Cannot find password: {rowid}', 404)
return request.page('password-edit', {'password': item}) return request.page('password-edit', {'password': row})
@Local.route('/passwords/update', '/passwords/update/{rowid}') @Local.route('/passwords/update', '/passwords/update/{rowid}')
@ -330,35 +329,29 @@ def passwords_update(handler, request, rowid=None):
query = request.query.copy() query = request.query.copy()
query.pop('redir', None) query.pop('redir', None)
print(query.get('label'))
if not query.get('label'): if not query.get('label'):
query['label'] = f'{query.username} @ {query.domain}' query['label'] = f'{query.username} @ {query.domain}'
print(query.get('label'))
for key, value in list(query.items()): for key, value in list(query.items()):
if isinstance(value, str) and not value: if isinstance(value, str) and not value:
del query[key] del query[key]
with handler.db.session as s: if not rowid:
if not rowid: row = handler.app.password.insert(**query)
row = s.put_password(**query) return request.ok_or_redirect(f'Created new password: {row.label}')
return request.ok_or_redirect(f'Created new password: {row.label}')
if not (row := s.fetch('passwords', id=rowid).one()): if not all(map(query.get, ('username', 'password', 'url'))):
return request.error(f'Cannot find password: {rowid}', 404) return request.error(f'Missing username, password or url', 400)
row = s.put_password_row(row, **query) row = handler.app.password.update(rowid, **query)
return request.ok_or_redirect(f'Updated password: {row.label}')
return request.ok_or_redirect(f'updated password: {row.label}')
@Local.route('/passwords/copy/{rowid}') @Local.route('/passwords/copy/{rowid}')
def passwords_copy(handler, request, rowid): def passwords_copy(handler, request, rowid):
with handler.db.session as s:
if not (row := s.fetch('passwords', id=rowid).one()): if not (row := handler.app.password.fetch(id=rowid)):
return request.error(f'Cannot find password: {rowid}', 404) return request.error(f'Cannot find password with ID: {rowid}', 404)
row.copy_password() row.copy_password()
return request.ok_or_redirect(f'Copied password for 60 seconds') 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}') @Local.route('/passwords/delete/{rowid}')
def passwords_update(handler, request, rowid): def passwords_update(handler, request, rowid):
with handler.db.session as s: if not (row := handler.app.password.fetch(id=rowid)):
try: return request.error(f'Cannot find password with ID: {rowid}', 404)
s.remove('passwords', id=rowid)
return request.ok_or_redirect(f'Deleted password: {label}')
except KeyError: handler.app.password.remove(rowid)
return request.error(f'Cannot find password: {rowid}', 404) return request.ok_or_redirect(f'Deleted password: {row.label}')
#return request.error(f'Cannot find password: {rowid}', 404)
### Preferences ### ### Preferences ###
@Local.route('/preferences') @Local.route('/preferences')
def preferences_home(handler, request): def preferences_home(handler, request):
context = DotDict( context = DotDict(
pass_backends = list(PASSWORD_STORAGE.keys()),
settings = { settings = {
'font': 'sans undertale', 'font': 'sans undertale',
'font-size': '14' 'font-size': '14'
@ -398,6 +392,9 @@ def preferences_update(handler, request):
if key == 'redir': if key == 'redir':
continue continue
elif key == 'pass_type':
handler.app.setup_password_storage(value)
row = s.put_config(key, value) row = s.put_config(key, value)
logging.verbose(f'Updated config: {row.key} = {row.value}') logging.verbose(f'Updated config: {row.key} = {row.value}')

View file

@ -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)

View file

@ -2,6 +2,37 @@
<!-- Generated with glade 3.38.2 --> <!-- Generated with glade 3.38.2 -->
<interface> <interface>
<requires lib="gtk+" version="3.24"/> <requires lib="gtk+" version="3.24"/>
<object class="GtkDialog">
<property name="can-focus">False</property>
<property name="type-hint">dialog</property>
<child internal-child="vbox">
<object class="GtkBox">
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">2</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
<property name="layout-style">end</property>
<child>
<placeholder/>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
</child>
</object>
<object class="GtkImage" id="bookmark-icon"> <object class="GtkImage" id="bookmark-icon">
<property name="visible">True</property> <property name="visible">True</property>
<property name="can-focus">False</property> <property name="can-focus">False</property>
@ -198,6 +229,149 @@
<property name="can-focus">False</property> <property name="can-focus">False</property>
<property name="icon-name">window-close</property> <property name="icon-name">window-close</property>
</object> </object>
<object class="GtkDialog" id="password-login">
<property name="can-focus">False</property>
<property name="title" translatable="yes">Password Storage Login</property>
<property name="type-hint">dialog</property>
<child internal-child="vbox">
<object class="GtkBox">
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">2</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
<property name="layout-style">end</property>
<child>
<object class="GtkButton" id="password-login-cancel">
<property name="label" translatable="yes">Cancel</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="password-login-reset">
<property name="label" translatable="yes">Reset</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="password-login-login">
<property name="label" translatable="yes">Login</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<!-- n-columns=2 n-rows=4 -->
<object class="GtkGrid">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="row-spacing">5</property>
<property name="column-spacing">5</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Password</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">3</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="password-login-password">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="hexpand">True</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">3</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="password-login-email">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="hexpand">True</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">E-Mail</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="password-login-error">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">1</property>
<property name="width">2</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Login to your Bitwarden account</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
<property name="width">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
</object>
<object class="GtkAdjustment" id="preferences-closed_tabs_limit-adjustment"> <object class="GtkAdjustment" id="preferences-closed_tabs_limit-adjustment">
<property name="upper">1000</property> <property name="upper">1000</property>
<property name="value">100</property> <property name="value">100</property>

View file

@ -46,6 +46,11 @@
"options": [], "options": [],
"url": null "url": null
}, },
"nodejs-bin": {
"version": "18.4.0a4",
"options": [],
"url": null
},
"objgraph": { "objgraph": {
"version": "3.5.0", "version": "3.5.0",
"options": [], "options": [],
@ -70,6 +75,11 @@
"version": "0.2.9", "version": "0.2.9",
"options": [], "options": [],
"url": null "url": null
},
"secretstorage": {
"version": "3.3.2",
"options": [],
"url": null
} }
}, },
"watcher": { "watcher": {
@ -88,7 +98,9 @@
"ignore_dirs": [ "ignore_dirs": [
"build", "build",
"config", "config",
"data" "data",
"localweb",
"localweb2"
], ],
"ignore_files": [ "ignore_files": [
"reload.py", "reload.py",

View file

@ -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 izzylib-sql@git+https://git.barkshark.xyz/izaliamae/izzylib-sql
lxml==4.6.3 lxml==4.6.3
mastodon.py==1.5.1 mastodon.py==1.5.1
nodejs-bin==18.4.0a4
objgraph==3.5.0 objgraph==3.5.0
pillow==8.3.2 pillow==8.3.2
psutil==5.8.0 psutil==5.8.0
pygobject==3.38.0 pygobject==3.38.0
pysftp==0.2.9 pysftp==0.2.9
secretstorage==3.3.2

View file

@ -33,20 +33,22 @@ python_requires = >= 3.8
packages = packages =
barkshark_web barkshark_web
setup_requires = 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 beautifulsoup4==4.9.3
click==8.1.0 click==8.1.0
dasbus==1.6 dasbus==1.6
http-router==2.6.5 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 lxml==4.6.3
mastodon.py==1.5.1 mastodon.py==1.5.1
nodejs-bin==18.4.0a4
objgraph==3.5.0 objgraph==3.5.0
pillow==8.3.2 pillow==8.3.2
psutil==5.8.0 psutil==5.8.0
pygobject==3.38.0 pygobject==3.38.0
pysftp==0.2.9 pysftp==0.2.9
secretstorage==3.3.2
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =