diff --git a/.gitignore b/.gitignore index 589dd635f..d14fbfc75 100644 --- a/.gitignore +++ b/.gitignore @@ -182,7 +182,6 @@ dmypy.json # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ [Bb]in [Ii]nclude -[Ll]ib [Ll]ib64 [Ll]ocal [Ss]cripts diff --git a/passbook/lib/__init__.py b/passbook/lib/__init__.py new file mode 100644 index 000000000..428b865b5 --- /dev/null +++ b/passbook/lib/__init__.py @@ -0,0 +1,2 @@ +"""passbook lib""" +default_app_config = 'passbook.lib.apps.PassbookLibConfig' diff --git a/passbook/lib/admin.py b/passbook/lib/admin.py new file mode 100644 index 000000000..e76b90b73 --- /dev/null +++ b/passbook/lib/admin.py @@ -0,0 +1,22 @@ +"""passbook core admin""" + +from django.apps import apps +from django.contrib import admin +from django.contrib.admin.sites import AlreadyRegistered +from django.contrib.auth.admin import UserAdmin + +from passbook.core.models import User + + +def admin_autoregister(app): + """Automatically register all models from app""" + app_models = apps.get_app_config(app).get_models() + for model in app_models: + try: + admin.site.register(model) + except AlreadyRegistered: + pass + + +admin.site.register(User, UserAdmin) +admin_autoregister('passbook_core') diff --git a/passbook/lib/apps.py b/passbook/lib/apps.py new file mode 100644 index 000000000..79315535c --- /dev/null +++ b/passbook/lib/apps.py @@ -0,0 +1,9 @@ +"""passbook lib app config""" +from django.apps import AppConfig + + +class PassbookLibConfig(AppConfig): + """passbook lib app config""" + + name = 'passbook.lib' + label = 'passbook_lib' diff --git a/passbook/lib/config.py b/passbook/lib/config.py new file mode 100644 index 000000000..6f551d20b --- /dev/null +++ b/passbook/lib/config.py @@ -0,0 +1,128 @@ +"""supervisr core config loader""" +import os +from collections import Mapping +from contextlib import contextmanager +from glob import glob +from logging import getLogger +from typing import Any + +import yaml +from django.conf import ImproperlyConfigured + +SEARCH_PATHS = [ + 'passbook/lib/default.yml', + '/etc/passbook/config.yml', + '.', +] + glob('/etc/passbook/config.d/*.yml', recursive=True) +LOGGER = getLogger(__name__) +ENVIRONMENT = os.getenv('PASSBOOK_ENV', 'local') + + +class ConfigLoader: + """Search through SEARCH_PATHS and load configuration""" + + __config = {} + __context_default = None + __sub_dicts = [] + + def __init__(self): + super().__init__() + base_dir = os.path.realpath(os.path.join( + os.path.dirname(__file__), '../..')) + for path in SEARCH_PATHS: + # Check if path is relative, and if so join with base_dir + if not os.path.isabs(path): + path = os.path.join(base_dir, path) + if os.path.isfile(path) and os.path.exists(path): + # Path is an existing file, so we just read it and update our config with it + self.update_from_file(path) + elif os.path.isdir(path) and os.path.exists(path): + # Path is an existing dir, so we try to read the env config from it + env_paths = [os.path.join(path, ENVIRONMENT+'.yml'), + os.path.join(path, ENVIRONMENT+'.env.yml')] + for env_file in env_paths: + if os.path.isfile(env_file) and os.path.exists(env_file): + # Update config with env file + self.update_from_file(env_file) + self.handle_secret_key() + + def handle_secret_key(self): + """Handle `secret_key_file`""" + if 'secret_key_file' in self.__config: + secret_key_file = self.__config.get('secret_key_file') + if os.path.isfile(secret_key_file) and os.path.exists(secret_key_file): + with open(secret_key_file) as file: + self.__config['secret_key'] = file.read().replace('\n', '') + + def update(self, root, updatee): + """Recursively update dictionary""" + for key, value in updatee.items(): + if isinstance(value, Mapping): + root[key] = self.update(root.get(key, {}), value) + else: + root[key] = value + return root + + def update_from_file(self, path: str): + """Update config from file contents""" + try: + with open(path) as file: + try: + self.update(self.__config, yaml.safe_load(file)) + except yaml.YAMLError as exc: + raise ImproperlyConfigured from exc + except PermissionError as exc: + LOGGER.warning('Permission denied while reading %s', path) + + def update_from_dict(self, update: dict): + """Update config from dict""" + self.__config.update(update) + + @contextmanager + def default(self, value: Any): + """Contextmanage that sets default""" + self.__context_default = value + yield + self.__context_default = None + + @contextmanager + # pylint: disable=invalid-name + def cd(self, sub: str): + """Contextmanager that descends into sub-dict. Can be chained.""" + self.__sub_dicts.append(sub) + yield + self.__sub_dicts.pop() + + def get(self, key: str, default=None) -> Any: + """Get value from loaded config file""" + if default is None: + default = self.__context_default + config_copy = self.raw + for sub in self.__sub_dicts: + config_copy = config_copy.get(sub, None) + return config_copy.get(key, default) + + @property + def raw(self) -> dict: + """Get raw config dictionary""" + return self.__config + + # pylint: disable=invalid-name + def y(self, path: str, default=None, sep='.') -> Any: + """Access attribute by using yaml path""" + if default is None: + default = self.__context_default + # Walk sub_dicts before parsing path + root = self.raw + for sub in self.__sub_dicts: + root = root.get(sub, None) + # Walk each component of the path + for comp in path.split(sep): + if comp in root: + root = root.get(comp) + else: + return default + return root + + +CONFIG = ConfigLoader() diff --git a/passbook/lib/decorators.py b/passbook/lib/decorators.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/lib/default.yml b/passbook/lib/default.yml new file mode 100644 index 000000000..42a715298 --- /dev/null +++ b/passbook/lib/default.yml @@ -0,0 +1,97 @@ +# This is the default configuration file +databases: + default: + engine: 'django.db.backends.sqlite3' + name: 'db.sqlite3' +log: + level: + console: DEBUG + file: DEBUG + file: /dev/null + syslog: + host: 127.0.0.1 + port: 514 +email: + host: localhost + port: 25 + user: '' + password: '' + use_tls: false + use_ssl: false + from: passbook +web: + listen: 0.0.0.0 + port: 8000 + threads: 30 +debug: true +secure_proxy_header: + HTTP_X_FORWARDED_PROTO: https +redis: localhost +# Error reporting, sends stacktrace to sentry.services.beryju.org +error_report_enabled: true + +passbook: + sign_up: + # Enables signup, created users are stored in internal Database and created in LDAP if ldap.create_users is true + enabled: true + password_reset: + # Enable password reset, passwords are reset in internal Database and in LDAP if ldap.reset_password is true + enabled: true + # Verification the user has to provide in order to be able to reset passwords. Can be any combination of `email`, `2fa`, `security_questions` + verification: + - email + # Text used in title, on login page and multiple other places + branding: passbook + login: + # Override URL used for logo + logo_url: null + # Override URL used for Background on Login page + bg_url: null + # Optionally add a subtext, placed below logo on the login page + subtext: This is placeholder text, only. Use this area to place any information or introductory message about your application that may be relevant for users. + footer: + links: + # Optionally add links to the footer on the login page + # - name: test + # href: https://test + # Specify which fields can be used to authenticate. Can be any combination of `username` and `email` + uid_fields: + - username + session: + remember_age: 2592000 # 60 * 60 * 24 * 30, one month +# Provider-specific settings +ldap: + # Completely enable or disable LDAP provider + enabled: false + # AD Domain, used to generate `userPrincipalName` + domain: corp.contoso.com + # Base DN in which passbook should look for users + base_dn: dn=corp,dn=contoso,dn=com + # LDAP field which is used to set the django username + username_field: sAMAccountName + # LDAP server to connect to, can be set to `` + server: + name: corp.contoso.com + use_tls: false + # Bind credentials, used for account creation + bind: + username: Administraotr@corp.contoso.com + password: VerySecurePassword! + # Which field from `uid_fields` maps to which LDAP Attribute + login_field_map: + username: sAMAccountName + email: mail # or userPrincipalName + # Create new users in LDAP upon sign-up + create_users: true + # Reset LDAP password when user reset their password + reset_password: true +oauth_client: + # List of python packages with sources types to load. + source_tyoes: + - passbook.oauth_client.source_types.discord + - passbook.oauth_client.source_types.facebook + - passbook.oauth_client.source_types.github + - passbook.oauth_client.source_types.google + - passbook.oauth_client.source_types.reddit + - passbook.oauth_client.source_types.supervisr + - passbook.oauth_client.source_types.twitter diff --git a/passbook/lib/fields.py b/passbook/lib/fields.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/lib/models.py b/passbook/lib/models.py new file mode 100644 index 000000000..d5efc972c --- /dev/null +++ b/passbook/lib/models.py @@ -0,0 +1,22 @@ +"""Generic models""" +from uuid import uuid4 + +from django.db import models + + +class CreatedUpdatedModel(models.Model): + """Base Abstract Model to save created and update""" + created = models.DateField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class UUIDModel(models.Model): + """Abstract base model which uses a UUID as primary key""" + + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) + + class Meta: + abstract = True diff --git a/passbook/lib/templatetags/is_active.py b/passbook/lib/templatetags/is_active.py new file mode 100644 index 000000000..200da9098 --- /dev/null +++ b/passbook/lib/templatetags/is_active.py @@ -0,0 +1,56 @@ +"""passbook lib navbar Templatetag""" +from logging import getLogger + +from django import template +from django.urls import reverse + +register = template.Library() + +LOGGER = getLogger(__name__) + + +@register.simple_tag(takes_context=True) +def is_active(context, *args, **kwargs): + """Return whether a navbar link is active or not.""" + request = context.get('request') + app_name = kwargs.get('app_name', None) + if not request.resolver_match: + return '' + for url in args: + short_url = url.split(':')[1] if ':' in url else url + # Check if resolve_match matches + if request.resolver_match.url_name.startswith(url) or \ + request.resolver_match.url_name.startswith(short_url): + # Monkeypatch app_name: urls from core have app_name == '' + # since the root urlpatterns have no namespace + if app_name and request.resolver_match.app_name == app_name: + return 'active' + if app_name is None: + return 'active' + return '' + + +@register.simple_tag(takes_context=True) +def is_active_url(context, view, *args, **kwargs): + """Return whether a navbar link is active or not.""" + + matching_url = reverse(view, args=args, kwargs=kwargs) + request = context.get('request') + if not request.resolver_match: + return '' + if matching_url == request.path: + return 'active' + return '' + + +@register.simple_tag(takes_context=True) +def is_active_app(context, *args): + """Return True if current link is from app""" + + request = context.get('request') + if not request.resolver_match: + return '' + for app_name in args: + if request.resolver_match.app_name == app_name: + return 'active' + return '' diff --git a/passbook/lib/templatetags/reflection.py b/passbook/lib/templatetags/reflection.py new file mode 100644 index 000000000..c26356451 --- /dev/null +++ b/passbook/lib/templatetags/reflection.py @@ -0,0 +1,93 @@ +"""Supervisr Core Reflection templatetags Templatetag""" +from logging import getLogger + +from django import template +from django.apps import AppConfig +from django.core.cache import cache +from django.urls import reverse +from django.urls.exceptions import NoReverseMatch + +register = template.Library() +LOGGER = getLogger(__name__) + + +def get_key_unique(context): + """Get a unique key for cache based on user""" + uniq = '' + if 'request' in context: + user = context.get('request').user + if user.is_authenticated: + uniq = context.get('request').user.email + else: + # This should never be reached as modlist requires admin rights + uniq = 'anon' # pragma: no cover + return uniq + +# @register.simple_tag(takes_context=True) +# def sv_reflection_admin_modules(context): +# """Get a list of all modules and their admin page""" +# key = 'sv_reflection_admin_modules_%s' % get_key_unique(context) +# if not cache.get(key): +# view_list = [] +# for app in get_apps(): +# title = app.title_modifier(context.request) +# url = app.admin_url_name +# view_list.append({ +# 'url': url, +# 'default': True if url == SupervisrAppConfig.admin_url_name else False, +# 'name': title, +# }) +# sorted_list = sorted(view_list, key=lambda x: x.get('name')) +# cache.set(key, sorted_list, 1000) +# return sorted_list +# return cache.get(key) # pragma: no cover + + +# @register.simple_tag(takes_context=True) +# def sv_reflection_user_modules(context): +# """Get a list of modules that have custom user settings""" +# key = 'sv_reflection_user_modules_%s' % get_key_unique(context) +# if not cache.get(key): +# app_list = [] +# for app in get_apps(): +# if not app.name.startswith('supervisr.mod'): +# continue +# view = app.view_user_settings +# if view is not None: +# app_list.append({ +# 'title': app.title_modifier(context.request), +# 'view': '%s:%s' % (app.label, view) +# }) +# sorted_list = sorted(app_list, key=lambda x: x.get('title')) +# cache.set(key, sorted_list, 1000) +# return sorted_list +# return cache.get(key) # pragma: no cover + + +# @register.simple_tag(takes_context=True) +# def sv_reflection_navbar_modules(context): +# """Get a list of subapps for the navbar""" +# key = 'sv_reflection_navbar_modules_%s' % get_key_unique(context) +# if not cache.get(key): +# app_list = [] +# for app in get_apps(): +# LOGGER.debug("Considering %s for Navbar", app.label) +# title = app.title_modifier(context.request) +# if app.navbar_enabled(context.request): +# index = getattr(app, 'index', None) +# if not index: +# index = '%s:index' % app.label +# try: +# reverse(index) +# LOGGER.debug("Module %s made it with '%s'", app.name, index) +# app_list.append({ +# 'label': app.label, +# 'title': title, +# 'index': index +# }) +# except NoReverseMatch: +# LOGGER.debug("View '%s' not reversable, ignoring %s", index, app.name) +# sorted_list = sorted(app_list, key=lambda x: x.get('label')) +# cache.set(key, sorted_list, 1000) +# return sorted_list +# return cache.get(key) # pragma: no cover diff --git a/passbook/lib/templatetags/utils.py b/passbook/lib/templatetags/utils.py new file mode 100644 index 000000000..9f33fd853 --- /dev/null +++ b/passbook/lib/templatetags/utils.py @@ -0,0 +1,158 @@ +"""passbook lib Templatetags""" +import glob +import os +import socket +from urllib.parse import urljoin + +from django import template +from django.apps import apps +from django.conf import settings +from django.db.models import Model +from django.template.loaders.app_directories import get_app_template_dirs +from django.urls import reverse +from django.utils.translation import ugettext as _ + +from passbook.lib.utils.reflection import path_to_class +from passbook.lib.utils.urls import is_url_absolute + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def back(context): + """Return a link back (either from GET paramter or referer.""" + + request = context.get('request') + url = '' + if 'HTTP_REFERER' in request.META: + url = request.META.get('HTTP_REFERER') + if 'back' in request.GET: + url = request.GET.get('back') + + if not is_url_absolute(url): + return url + return '' + + +@register.filter('fieldtype') +def fieldtype(field): + """Return classname""" + # if issubclass(field.__class__, CastableModel): + # field = field.cast() + if isinstance(field.__class__, Model) or issubclass(field.__class__, Model): + return field._meta.verbose_name + return field.__class__.__name__ + + +@register.simple_tag +def setting(key, default=''): + """Returns a setting from the settings.py file. If Key is blocked, return default""" + return getattr(settings, key, default) + + +@register.simple_tag +def hostname(): + """Return the current Host's short hostname""" + return socket.gethostname() + + +@register.simple_tag +def fqdn(): + """Return the current Host's FQDN.""" + return socket.getfqdn() + + +@register.filter('pick') +def pick(cont, arg, fallback=''): + """Iterate through arg and return first choice which is not None""" + choices = arg.split(',') + for choice in choices: + if choice in cont and cont[choice] is not None: + return cont[choice] + return fallback + + +@register.simple_tag(takes_context=True) +def title(context, *title): + """Return either just branding or title - branding""" + branding = Setting.get('branding', default='supervisr') + if not title: + return branding + # Include App Title in title + app = '' + if context.request.resolver_match and context.request.resolver_match.namespace != '': + dj_app = None + namespace = context.request.resolver_match.namespace.split(':')[0] + # New label (App URL Namespace == App Label) + dj_app = apps.get_app_config(namespace) + title_modifier = getattr(dj_app, 'title_modifier', None) + if title_modifier: + app_title = dj_app.title_modifier(context.request) + app = app_title + ' -' + return _("%(title)s - %(app)s %(branding)s" % { + 'title': ' - '.join([str(x) for x in title]), + 'branding': branding, + 'app': app, + }) + + +@register.simple_tag +def supervisr_setting(key, namespace='supervisr.core', default=''): + """Get a setting from the database. Returns default is setting doesn't exist.""" + return Setting.get(key=key, namespace=namespace, default=default) + + +@register.simple_tag() +def media(*args): + """Iterate through arg and return full media URL""" + urls = [] + for arg in args: + urls.append(urljoin(settings.MEDIA_URL, str(arg))) + if len(urls) == 1: + return urls[0] + return urls + + +@register.simple_tag +def url_unpack(view, kwargs): + """Reverses a URL with kwargs which are stored in a dict""" + return reverse(view, kwargs=kwargs) + + +@register.simple_tag +def template_wildcard(*args): + """Return a list of all templates in dir""" + templates = [] + for tmpl_dir in args: + for app_templates in get_app_template_dirs('templates'): + path = os.path.join(app_templates, tmpl_dir) + if os.path.isdir(path): + files = sorted(glob.glob(path + '*.html')) + for file in files: + templates.append(os.path.relpath(file, start=app_templates)) + return templates + + +@register.simple_tag(takes_context=True) +def related_models(context, model_path): + """Return list of models which have a Relationship to current user""" + + request = context.get('request', None) + if not request: + # No Request -> no user -> return empty + return [] + user = request.user + + model = path_to_class(model_path) + if not issubclass(model, UserAcquirable): + # model_path is not actually a module + # so we can't assume that it's usable + return [] + + return model.objects.filter(users__in=[user]) + + +@register.filter('unslug') +def unslug(_input): + """Convert slugs back into normal strings""" + return _input.replace('-', ' ').replace('_', ' ') diff --git a/passbook/lib/utils/__init__.py b/passbook/lib/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/lib/utils/reflection.py b/passbook/lib/utils/reflection.py new file mode 100644 index 000000000..da8f17de8 --- /dev/null +++ b/passbook/lib/utils/reflection.py @@ -0,0 +1,17 @@ +"""passbook lib reflection utilities""" +from importlib import import_module + + +def class_to_path(cls): + """Turn Class (Class or instance) into module path""" + return '%s.%s' % (cls.__module__, cls.__name__) + + +def path_to_class(path): + """Import module and return class""" + if not path: + return None + parts = path.split('.') + package = '.'.join(parts[:-1]) + _class = getattr(import_module(package), parts[-1]) + return _class diff --git a/passbook/lib/utils/urls.py b/passbook/lib/utils/urls.py new file mode 100644 index 000000000..416266247 --- /dev/null +++ b/passbook/lib/utils/urls.py @@ -0,0 +1,7 @@ +"""URL-related utils""" +from urllib.parse import urlparse + + +def is_url_absolute(url): + """Check if domain is absolute to prevent user from being redirect somewhere else""" + return bool(urlparse(url).netloc)