This commit is contained in:
Jens Langhammer 2018-11-14 19:14:14 +01:00
parent 8d4a14c4f4
commit 79490984d1
No known key found for this signature in database
GPG Key ID: BEBC05297D92821B
15 changed files with 611 additions and 1 deletions

1
.gitignore vendored
View File

@ -182,7 +182,6 @@ dmypy.json
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
[Bb]in [Bb]in
[Ii]nclude [Ii]nclude
[Ll]ib
[Ll]ib64 [Ll]ib64
[Ll]ocal [Ll]ocal
[Ss]cripts [Ss]cripts

2
passbook/lib/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""passbook lib"""
default_app_config = 'passbook.lib.apps.PassbookLibConfig'

22
passbook/lib/admin.py Normal file
View File

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

9
passbook/lib/apps.py Normal file
View File

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

128
passbook/lib/config.py Normal file
View File

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

View File

97
passbook/lib/default.yml Normal file
View File

@ -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 <passbook@domain.tld>
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 `<domain_name>`
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

0
passbook/lib/fields.py Normal file
View File

22
passbook/lib/models.py Normal file
View File

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

View File

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

View File

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

View File

@ -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('_', ' ')

View File

View File

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

View File

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