├── .travis.yml ├── LICENSE ├── README.rst ├── allowedsites.py ├── runtests.py ├── setup.py ├── test_settings.py ├── test_urls.py ├── tests.py └── tox.ini /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | notifications: 4 | email: false 5 | 6 | env: 7 | - TOX_ENV=py26-1.6 8 | - TOX_ENV=py27-1.6 9 | - TOX_ENV=py27-1.7 10 | - TOX_ENV=py27-1.8 11 | - TOX_ENV=py33-1.6 12 | - TOX_ENV=py33-1.7 13 | - TOX_ENV=py33-1.8 14 | - TOX_ENV=py34-1.6 15 | - TOX_ENV=py34-1.7 16 | - TOX_ENV=py34-1.8 17 | - TOX_ENV=pypy-1.6 18 | - TOX_ENV=pypy-1.7 19 | - TOX_ENV=pypy-1.8 20 | 21 | install: 22 | - pip install --upgrade tox 23 | 24 | script: 25 | - tox -e $TOX_ENV 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Keryn Knight 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | 24 | The views and conclusions contained in the software and documentation are those 25 | of the authors and should not be interpreted as representing official policies, 26 | either expressed or implied, of the FreeBSD Project. 27 | 28 | This license applies to version 0.1.0 of django-allowedsites. 29 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | django-allowedsites 3 | =================== 4 | 5 | Django 1.6+ library for setting your ``ALLOWED_HOSTS`` based on the domains in ``django.contrib.sites`` 6 | 7 | .. image:: https://travis-ci.org/kezabelle/django-allowedsites.svg?branch=master 8 | :target: https://travis-ci.org/kezabelle/django-allowedsites 9 | 10 | Usage is something like the following, in your ``settings.py`` or equivalent:: 11 | 12 | from allowedsites import AllowedSites 13 | ALLOWED_HOSTS = AllowedSites(defaults=('mytestsite.com',)) 14 | 15 | Or, if you want to use your cache backend:: 16 | 17 | from allowedsites import CachedAllowedSites 18 | ALLOWED_HOSTS = CachedAllowedSites() 19 | 20 | A single key, ``allowedsites`` will be inserted containing an unsorted collection 21 | of all the domains that are in the ``django.contrib.sites``. For the sake of allowing 22 | multiple processes to keep up to date with the ``Site`` values without hitting 23 | the database, using a shared cache (ie: not ``LocMemCache``) is encouraged. 24 | 25 | The ``CachedAllowedSites`` also provides an ``update_cache`` class method which 26 | may be used as a signal listener:: 27 | 28 | from django.db.models.signals import post_save 29 | from django.contrib.sites.models import Site 30 | post_save.connect(CachedAllowedSites.update_cache, sender=Site, 31 | dispatch_uid='update_allowedsites') 32 | 33 | You can modify the the defaults:: 34 | 35 | from allowedsites import AllowedSites 36 | ALLOWED_HOSTS = AllowedSites(defaults=('mytestsite.com',)) 37 | ALLOWED_HOSTS += AllowedSites(defaults=('anothersite.net',)) 38 | ALLOWED_HOSTS -= AllowedSites(defaults=('mytestsite.com',)) 39 | # ultimately, only anothersite.net is in the defaults 40 | 41 | Other uses? 42 | ----------- 43 | 44 | It *may* work with `django-csp`_ (Content Security Policy headers), 45 | `django-dcors`_ (Cross-Origin Resource Sharing headers) and others. I don't know. 46 | 47 | .. _django-csp: https://github.com/mozilla/django-csp 48 | .. _django-dcors: https://github.com/prasanthn/django-dcors 49 | -------------------------------------------------------------------------------- /allowedsites.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class ForceAllowedHostCheck(object): 4 | def process_request(self, request): 5 | request.get_host() 6 | return None 7 | 8 | 9 | class Sites(object): 10 | """ 11 | Sites are unordered, because seriously who cares. 12 | """ 13 | 14 | __slots__ = ('defaults',) 15 | 16 | def __init__(self, defaults=None): 17 | if defaults is None: 18 | defaults = () 19 | self.defaults = frozenset(defaults) 20 | 21 | def get_raw_sites(self): 22 | from django.contrib.sites.models import Site 23 | return Site.objects.all().iterator() 24 | 25 | def get_domains(self): 26 | """ 27 | Yields domains *without* any ports defined, as that's what 28 | `validate_host` wants 29 | """ 30 | from django.http.request import split_domain_port 31 | raw_sites = self.get_raw_sites() 32 | domains = set() 33 | raw_domains = (site.domain for site in raw_sites) 34 | for domain in raw_domains: 35 | domain_host, domain_port = split_domain_port(domain) 36 | domains.add(domain_host) 37 | return frozenset(domains) 38 | 39 | def get_merged_allowed_hosts(self): 40 | sites = self.get_domains() 41 | return self.defaults.union(sites) 42 | 43 | def __iter__(self): 44 | return iter(self.get_merged_allowed_hosts()) 45 | 46 | def __repr__(self): 47 | return '<{mod}.{cls} for sites: {sites}>'.format( 48 | mod=self.__class__.__module__, cls=self.__class__.__name__, 49 | sites=str(self)) 50 | 51 | def __str__(self): 52 | return ', '.join(self.get_merged_allowed_hosts()) 53 | 54 | __unicode__ = __str__ 55 | 56 | def __contains__(self, other): 57 | if other in self.defaults: 58 | return True 59 | if other in self.get_domains(): 60 | return True 61 | return False 62 | 63 | def __len__(self): 64 | return len(self.get_merged_allowed_hosts()) 65 | 66 | def __nonzero__(self): 67 | # ask in order, so that a query *may* not be necessary. 68 | if len(self.defaults) > 0: 69 | return True 70 | if len(self.get_domains()) > 0: 71 | return True 72 | return False 73 | 74 | __bool__ = __nonzero__ 75 | 76 | def __eq__(self, other): 77 | # fail early. 78 | if self.defaults != other.defaults: 79 | return False 80 | side_a = self.get_merged_allowed_hosts() 81 | side_b = other.get_merged_allowed_hosts() 82 | return side_a == side_b 83 | 84 | def __add__(self, other): 85 | more_defaults = self.defaults.union(other.defaults) 86 | return self.__class__(defaults=more_defaults) 87 | 88 | def __sub__(self, other): 89 | less_defaults = self.defaults.difference(other.defaults) 90 | return self.__class__(defaults=less_defaults) 91 | 92 | 93 | class AllowedSites(Sites): 94 | """ 95 | This only exists to allow isinstance to differentiate between 96 | the various Site subclasses 97 | """ 98 | __slots__ = ('defaults',) 99 | 100 | 101 | class CachedAllowedSites(Sites): 102 | """ 103 | Sets the given ``Site`` domains into the ``default`` cache. 104 | Expects the cache to be shared between processes, such that 105 | a signal listening for ``Site`` creates will be able to add to 106 | the cache's contents for other processes to pick up on. 107 | """ 108 | __slots__ = ('defaults', 'key') 109 | 110 | def __init__(self, *args, **kwargs): 111 | self.key = 'allowedsites' 112 | super(CachedAllowedSites, self).__init__(*args, **kwargs) 113 | 114 | def _get_cached_sites(self): 115 | from django.core.cache import cache 116 | results = cache.get(self.key, None) 117 | return results 118 | 119 | def get_merged_allowed_hosts(self): 120 | sites = self._get_cached_sites() 121 | if sites is None: 122 | sites = self._set_cached_sites() 123 | return self.defaults.union(sites) 124 | 125 | def _set_cached_sites(self, **kwargs): 126 | """ 127 | Forces whatever is in the DB into the cache. 128 | """ 129 | from django.core.cache import cache 130 | in_db = self.get_domains() 131 | cache.set(self.key, in_db) 132 | return in_db 133 | 134 | @classmethod 135 | def update_cache(cls, **kwargs): 136 | """ 137 | May be used as a post_save or post_delete signal listener. 138 | Replaces whatever is in the cache with the sites in the DB 139 | *at this moment* 140 | """ 141 | cls()._set_cached_sites(**kwargs) 142 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | 6 | from django.conf import settings 7 | import django 8 | 9 | 10 | def get_settings(): 11 | import test_settings 12 | setting_attrs = {} 13 | for attr in dir(test_settings): 14 | if attr.isupper(): 15 | setting_attrs[attr] = getattr(test_settings, attr) 16 | return setting_attrs 17 | 18 | 19 | def runtests(): 20 | if not settings.configured: 21 | settings.configure(**get_settings()) 22 | 23 | # Compatibility with Django 1.7's stricter initialization 24 | if hasattr(django, 'setup'): 25 | django.setup() 26 | 27 | parent = os.path.dirname(os.path.abspath(__file__)) 28 | sys.path.insert(0, parent) 29 | 30 | from django.test.runner import DiscoverRunner as Runner 31 | # reminder to self: an ImportError in the tests may either turn up 32 | # or may cause this thing to barf with this crap: 33 | # AttributeError: 'module' object has no attribute 'tests' 34 | test_args = ['.'] 35 | failures = Runner( 36 | verbosity=2, interactive=True, failfast=False).run_tests(test_args) 37 | sys.exit(failures) 38 | 39 | 40 | if __name__ == '__main__': 41 | runtests() 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | import os 5 | from setuptools import setup 6 | 7 | 8 | HERE = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | 11 | def make_readme(root_path): 12 | FILES = ('README.rst', 'LICENSE', 'CHANGELOG', 'CONTRIBUTORS') 13 | for filename in FILES: 14 | filepath = os.path.realpath(os.path.join(HERE, filename)) 15 | if os.path.isfile(filepath): 16 | with open(filepath, mode='r') as f: 17 | yield f.read() 18 | 19 | 20 | LONG_DESCRIPTION = "\r\n\r\n----\r\n\r\n".join(make_readme(HERE)) 21 | 22 | setup( 23 | name='django-allowedsites', 24 | version='0.1.0', 25 | author='Keryn Knight', 26 | author_email='python-package@kerynknight.com', 27 | description="dynamic ALLOWED_HOSTS", 28 | long_description=LONG_DESCRIPTION, 29 | packages=[], 30 | py_modules=['allowedsites'], 31 | include_package_data=True, 32 | install_requires=[ 33 | 'Django>=1.4', 34 | ], 35 | test_suite='runtests.runtests', 36 | zip_safe=False, 37 | license="BSD License", 38 | classifiers=[ 39 | 'Development Status :: 4 - Beta', 40 | 'Intended Audience :: Developers', 41 | 'License :: OSI Approved :: BSD License', 42 | 'Framework :: Django', 43 | 'Natural Language :: English', 44 | 'Environment :: Web Environment', 45 | 'Topic :: Internet :: WWW/HTTP', 46 | "Programming Language :: Python :: 2", 47 | 'Programming Language :: Python :: 2.6', 48 | 'Programming Language :: Python :: 2.7', 49 | "Programming Language :: Python :: 3", 50 | 'Programming Language :: Python :: 3.3', 51 | 'Programming Language :: Python :: 3.4', 52 | ], 53 | ) 54 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | import os 4 | 5 | try: 6 | logging.getLogger('allowedsites').addHandler(logging.NullHandler()) 7 | except AttributeError: # < Python 2.7 8 | pass 9 | 10 | DATABASES = { 11 | 'default': { 12 | 'ENGINE': 'django.db.backends.sqlite3', 13 | 'NAME': ':memory:', 14 | } 15 | } 16 | 17 | INSTALLED_APPS = ( 18 | 'django.contrib.sites', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.auth', 21 | ) 22 | 23 | SKIP_SOUTH_TESTS = True 24 | SOUTH_TESTS_MIGRATE = False 25 | 26 | ROOT_URLCONF = 'test_urls' 27 | 28 | # Use a fast hasher to speed up tests. 29 | PASSWORD_HASHERS = ( 30 | 'django.contrib.auth.hashers.MD5PasswordHasher', 31 | ) 32 | 33 | SITE_ID = 1 34 | 35 | TEMPLATE_CONTEXT_PROCESSORS = () 36 | 37 | HERE_DIR = os.path.abspath(os.path.dirname(__file__)) 38 | 39 | TEMPLATE_DIRS = () 40 | 41 | SILENCED_SYSTEM_CHECKS = [ 42 | "1_7.W001", 43 | ] 44 | 45 | USE_TZ = True 46 | -------------------------------------------------------------------------------- /test_urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.conf.urls import patterns, url, include 3 | from django.contrib import admin 4 | 5 | 6 | urlpatterns = patterns('', 7 | url(r'^admin_mountpoint/', include(admin.site.urls)), 8 | ) 9 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from allowedsites import AllowedSites 3 | from django.contrib.sites.models import Site 4 | from django.http.request import validate_host 5 | from django.test import TestCase as TestCaseUsingDB 6 | 7 | 8 | class AllowedSitesTestCase(TestCaseUsingDB): 9 | def setUp(self): 10 | Site.objects.all().delete() # remove default 11 | Site.objects.create(domain='example.com:8080', name='test') 12 | Site.objects.create(domain='example.org:8181', name='test 2') 13 | 14 | def test_get_raw_sites_unevaluated(self): 15 | allowed_cls = AllowedSites(defaults=['yay.com']) 16 | with self.assertNumQueries(0): 17 | allowed_cls.get_raw_sites() 18 | 19 | def test_get_raw_sites_evaluated(self): 20 | allowed_cls = AllowedSites(defaults=['yay.com']) 21 | with self.assertNumQueries(1): 22 | data = tuple(allowed_cls.get_raw_sites()) 23 | self.assertEqual(len(data), 2) 24 | # do it again to demonstrate it uses iterator() 25 | with self.assertNumQueries(1): 26 | data = tuple(allowed_cls.get_raw_sites()) 27 | self.assertEqual(len(data), 2) 28 | 29 | def test_get_domains(self): 30 | allowed_cls = AllowedSites(defaults=['yay.com']) 31 | with self.assertNumQueries(1): 32 | data = allowed_cls.get_domains() 33 | self.assertEqual(data, frozenset(['example.com', 'example.org'])) 34 | 35 | def test_iterable(self): 36 | """ 37 | this is what Django does internally in django.http.request 38 | allowed_cls is synonymous with settings.ALLOWED_HOSTS 39 | """ 40 | allowed_cls = AllowedSites(defaults=['yay.com']) 41 | with self.assertNumQueries(1): 42 | self.assertTrue(validate_host('example.com', allowed_cls)) 43 | with self.assertNumQueries(1): 44 | self.assertTrue(validate_host('example.org', allowed_cls)) 45 | with self.assertNumQueries(1): 46 | self.assertFalse(validate_host('djangoproject.com', allowed_cls)) 47 | # ideally this should be 0 queries, because it's a default ... 48 | with self.assertNumQueries(1): 49 | self.assertTrue(validate_host('yay.com', allowed_cls)) 50 | 51 | def test_length(self): 52 | allowed_cls = AllowedSites(defaults=['yay.com']) 53 | self.assertEqual(len(allowed_cls), 3) 54 | 55 | def test_containment(self): 56 | allowed_cls = AllowedSites(defaults=['yay.com']) 57 | self.assertIn('example.org', allowed_cls) 58 | self.assertNotIn('djangoproject.org', allowed_cls) 59 | 60 | def test_bool_true(self): 61 | allowed_cls = AllowedSites() 62 | self.assertTrue(allowed_cls) 63 | 64 | def test_bool_false(self): 65 | allowed_cls = AllowedSites() 66 | Site.objects.all().delete() 67 | self.assertFalse(allowed_cls) 68 | 69 | def test_equality(self): 70 | allowed_cls = AllowedSites() 71 | allowed_cls2 = AllowedSites() 72 | self.assertEqual(allowed_cls, allowed_cls2) 73 | 74 | def test_inequality(self): 75 | allowed_cls = AllowedSites() 76 | allowed_cls2 = AllowedSites(defaults='test.com') 77 | self.assertNotEqual(allowed_cls, allowed_cls2) 78 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion=1.6.7 3 | envlist = 4 | py26-1.6, 5 | py27-1.6, 6 | py27-1.7, 7 | py27-1.8, 8 | py33-1.6, 9 | py33-1.7, 10 | py33-1.8, 11 | py34-1.6, 12 | py34-1.7, 13 | py34-1.8, 14 | pypy-1.6, 15 | pypy-1.7, 16 | pypy-1.8 17 | 18 | [testenv] 19 | commands = 20 | python -B -tt -W ignore setup.py test 21 | 22 | [testenv:py26-1.6] 23 | basepython = python2.6 24 | usedevelop = True 25 | deps = 26 | Django == 1.6.11 27 | argparse == 1.1 28 | 29 | [testenv:py27-1.6] 30 | basepython = python2.7 31 | usedevelop = True 32 | deps = 33 | Django == 1.6.11 34 | 35 | [testenv:py27-1.7] 36 | basepython = python2.7 37 | usedevelop = True 38 | deps = 39 | Django == 1.7.7 40 | 41 | [testenv:py27-1.8] 42 | basepython = python2.7 43 | usedevelop = True 44 | deps = 45 | Django == 1.8 46 | 47 | [testenv:py33-1.6] 48 | basepython = python3.3 49 | usedevelop = True 50 | deps = 51 | Django == 1.6.11 52 | 53 | [testenv:py33-1.7] 54 | basepython = python3.3 55 | usedevelop = True 56 | deps = 57 | Django == 1.7.7 58 | 59 | [testenv:py33-1.8] 60 | basepython = python3.3 61 | usedevelop = True 62 | deps = 63 | Django == 1.8 64 | 65 | [testenv:py34-1.6] 66 | basepython = python3.4 67 | usedevelop = True 68 | deps = 69 | Django == 1.6.11 70 | 71 | [testenv:py34-1.7] 72 | basepython = python3.4 73 | usedevelop = True 74 | deps = 75 | Django == 1.7.7 76 | 77 | [testenv:py34-1.8] 78 | basepython = python3.4 79 | usedevelop = True 80 | deps = 81 | Django == 1.8 82 | 83 | [testenv:pypy-1.6] 84 | basepython = pypy 85 | usedevelop = True 86 | deps = 87 | Django == 1.6.11 88 | 89 | [testenv:pypy-1.7] 90 | basepython = pypy 91 | usedevelop = True 92 | deps = 93 | Django == 1.7.7 94 | 95 | [testenv:pypy-1.8] 96 | basepython = pypy 97 | usedevelop = True 98 | deps = 99 | Django == 1.8 100 | --------------------------------------------------------------------------------