Repository: wagtail/wagtail

Issue:
Allow multiple sites with same hostname but different ports
Currently Site.find_for_request only checks the hostname to match a site, the port is excluded.

In most cases this is fine but there are cases where the same hostname is used by more than one site where they each use a different port. The typical usecase is developing multiple sites on localhost.

Site.find_for_request can be enhanced to match on hostname and port without having to hardcode any special port number.

By using the current ORM get, if a MultipleObjectsReturned exception is returned for the hostname a secondary check can be made for a unique hostname and port. In that way a unique hostname Site always gets matched whether or not the port it was requested on matches the port it's been set to (so the out-of-the-box setup works).

At the same time a fix can also be made for the Django admin for Sites. Currently only hostname is checked for uniqueness, even if the port is different.

I'll fix this.  



Discussion:

Resolving patch:
diff --git a/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py b/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py
new file mode 100644
--- /dev/null
+++ b/wagtail/wagtailcore/migrations/0003_auto__del_unique_site_hostname__add_unique_site_hostname_port.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+from south.utils import datetime_utils as datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+    def forwards(self, orm):
+        # Removing unique constraint on 'Site', fields ['hostname']
+        db.delete_unique(u'wagtailcore_site', ['hostname'])
+
+        # Adding unique constraint on 'Site', fields ['hostname', 'port']
+        db.create_unique(u'wagtailcore_site', ['hostname', 'port'])
+
+
+    def backwards(self, orm):
+        # Removing unique constraint on 'Site', fields ['hostname', 'port']
+        db.delete_unique(u'wagtailcore_site', ['hostname', 'port'])
+
+        # Adding unique constraint on 'Site', fields ['hostname']
+        db.create_unique(u'wagtailcore_site', ['hostname'])
+
+
+    models = {
+        u'auth.group': {
+            'Meta': {'object_name': 'Group'},
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+            'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+        },
+        u'auth.permission': {
+            'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'},
+            'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+        },
+        u'auth.user': {
+            'Meta': {'object_name': 'User'},
+            'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+            'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+            'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+            'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+            'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}),
+            'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+        },
+        u'contenttypes.contenttype': {
+            'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+            'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+            'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+        },
+        u'wagtailcore.grouppagepermission': {
+            'Meta': {'object_name': 'GroupPagePermission'},
+            'group': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'page_permissions'", 'to': u"orm['auth.Group']"}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'group_permissions'", 'to': u"orm['wagtailcore.Page']"}),
+            'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '20'})
+        },
+        u'wagtailcore.page': {
+            'Meta': {'object_name': 'Page'},
+            'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'pages'", 'to': u"orm['contenttypes.ContentType']"}),
+            'depth': ('django.db.models.fields.PositiveIntegerField', [], {}),
+            'has_unpublished_changes': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'live': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+            'numchild': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}),
+            'owner': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'owned_pages'", 'null': 'True', 'to': u"orm['auth.User']"}),
+            'path': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
+            'search_description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+            'seo_title': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
+            'show_in_menus': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'slug': ('django.db.models.fields.SlugField', [], {'max_length': '50'}),
+            'title': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+            'url_path': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'})
+        },
+        u'wagtailcore.pagerevision': {
+            'Meta': {'object_name': 'PageRevision'},
+            'content_json': ('django.db.models.fields.TextField', [], {}),
+            'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'revisions'", 'to': u"orm['wagtailcore.Page']"}),
+            'submitted_for_moderation': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'})
+        },
+        u'wagtailcore.site': {
+            'Meta': {'unique_together': "(('hostname', 'port'),)", 'object_name': 'Site'},
+            'hostname': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
+            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+            'is_default_site': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+            'port': ('django.db.models.fields.IntegerField', [], {'default': '80'}),
+            'root_page': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'sites_rooted_here'", 'to': u"orm['wagtailcore.Page']"})
+        }
+    }
+
+    complete_apps = ['wagtailcore']
\ No newline at end of file
diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py
--- a/wagtail/wagtailcore/models.py
+++ b/wagtail/wagtailcore/models.py
@@ -15,6 +15,7 @@
 from django.conf import settings
 from django.template.response import TemplateResponse
 from django.utils.translation import ugettext_lazy as _
+from django.core.exceptions import ValidationError
 from django.utils.functional import cached_property
 
 from treebeard.mp_tree import MP_Node
@@ -26,29 +27,47 @@
 
 
 class SiteManager(models.Manager):
-    def get_by_natural_key(self, hostname):
-        return self.get(hostname=hostname)
+    def get_by_natural_key(self, hostname, port):
+        return self.get(hostname=hostname, port=port)
 
 
 class Site(models.Model):
-    hostname = models.CharField(max_length=255, unique=True, db_index=True)
+    hostname = models.CharField(max_length=255, db_index=True)
     port = models.IntegerField(default=80, help_text=_("Set this to something other than 80 if you need a specific port number to appear in URLs (e.g. development on port 8000). Does not affect request handling (so port forwarding still works)."))
     root_page = models.ForeignKey('Page', related_name='sites_rooted_here')
     is_default_site = models.BooleanField(default=False, help_text=_("If true, this site will handle requests for all other hostnames that do not have a site entry of their own"))
 
+    class Meta:
+        unique_together = ('hostname', 'port')
+
     def natural_key(self):
-        return (self.hostname,)
+        return (self.hostname, self.port)
 
     def __unicode__(self):
         return self.hostname + ("" if self.port == 80 else (":%d" % self.port)) + (" [default]" if self.is_default_site else "")
 
     @staticmethod
     def find_for_request(request):
-        """Find the site object responsible for responding to this HTTP request object"""
+        """
+            Find the site object responsible for responding to this HTTP
+            request object. Try:
+             - unique hostname first
+             - then hostname and port
+             - if there is no matching hostname at all, or no matching
+               hostname:port combination, fall back to the unique default site,
+               or raise an exception
+            NB this means that high-numbered ports on an extant hostname may
+            still be routed to a different hostname which is set as the default
+        """
         try:
-            hostname = request.META['HTTP_HOST'].split(':')[0]
-            # find a Site matching this specific hostname
-            return Site.objects.get(hostname=hostname)
+            hostname = request.META['HTTP_HOST'].split(':')[0] # KeyError here goes to the final except clause
+            try:
+                # find a Site matching this specific hostname
+                return Site.objects.get(hostname=hostname) # Site.DoesNotExist here goes to the final except clause
+            except Site.MultipleObjectsReturned:
+                # as there were more than one, try matching by port too
+                port = request.META['SERVER_PORT'] # KeyError here goes to the final except clause
+                return Site.objects.get(hostname=hostname, port=int(port)) # Site.DoesNotExist here goes to the final except clause
         except (Site.DoesNotExist, KeyError):
             # If no matching site exists, or request does not specify an HTTP_HOST (which
             # will often be the case for the Django test client), look for a catch-all Site.
@@ -64,6 +83,24 @@ def root_url(self):
         else:
             return 'http://%s:%d' % (self.hostname, self.port)
 
+    def clean_fields(self, exclude=None):
+        super(Site, self).clean_fields(exclude)
+        # Only one site can have the is_default_site flag set
+        try:
+            default = Site.objects.get(is_default_site=True)
+        except Site.DoesNotExist:
+            pass
+        except Site.MultipleObjectsReturned:
+            raise
+        else:
+            if self.is_default_site and self.pk != default.pk:
+                raise ValidationError(
+                    {'is_default_site': [
+                        _("%(hostname)s is already configured as the default site. You must unset that before you can save this site as default.")
+                        % { 'hostname': default.hostname }
+                        ]}
+                    )
+
     # clear the wagtail_site_root_paths cache whenever Site records are updated
     def save(self, *args, **kwargs):
         result = super(Site, self).save(*args, **kwargs)


Test patch:
diff --git a/wagtail/wagtailcore/tests/test_page_model.py b/wagtail/wagtailcore/tests/test_page_model.py
--- a/wagtail/wagtailcore/tests/test_page_model.py
+++ b/wagtail/wagtailcore/tests/test_page_model.py
@@ -9,30 +9,96 @@
 from wagtail.tests.models import EventPage, EventIndex, SimplePage
 
 
-class TestRouting(TestCase):
+class TestSiteRouting(TestCase):
     fixtures = ['test.json']
 
-    def test_find_site_for_request(self):
-        default_site = Site.objects.get(is_default_site=True)
+    def setUp(self):
+        self.default_site = Site.objects.get(is_default_site=True)
         events_page = Page.objects.get(url_path='/home/events/')
-        events_site = Site.objects.create(hostname='events.example.com', root_page=events_page)
-
+        about_page = Page.objects.get(url_path='/home/about-us/')
+        self.events_site = Site.objects.create(hostname='events.example.com', root_page=events_page)
+        self.alternate_port_events_site = Site.objects.create(hostname='events.example.com', root_page=events_page, port='8765')
+        self.about_site = Site.objects.create(hostname='about.example.com', root_page=about_page)
+        self.unrecognised_port = '8000'
+        self.unrecognised_hostname = 'unknown.site.com'
+
+    def test_no_host_header_routes_to_default_site(self):
         # requests without a Host: header should be directed to the default site
         request = HttpRequest()
         request.path = '/'
-        self.assertEqual(Site.find_for_request(request), default_site)
+        self.assertEqual(Site.find_for_request(request), self.default_site)
 
+    def test_valid_headers_route_to_specific_site(self):
         # requests with a known Host: header should be directed to the specific site
         request = HttpRequest()
         request.path = '/'
-        request.META['HTTP_HOST'] = 'events.example.com'
-        self.assertEqual(Site.find_for_request(request), events_site)
+        request.META['HTTP_HOST'] = self.events_site.hostname
+        request.META['SERVER_PORT'] = self.events_site.port
+        self.assertEqual(Site.find_for_request(request), self.events_site)
 
+    def test_ports_in_request_headers_are_respected(self):
+        # ports in the Host: header should be respected
+        request = HttpRequest()
+        request.path = '/'
+        request.META['HTTP_HOST'] = self.alternate_port_events_site.hostname
+        request.META['SERVER_PORT'] = self.alternate_port_events_site.port
+        self.assertEqual(Site.find_for_request(request), self.alternate_port_events_site)
+
+    def test_unrecognised_host_header_routes_to_default_site(self):
         # requests with an unrecognised Host: header should be directed to the default site
         request = HttpRequest()
         request.path = '/'
-        request.META['HTTP_HOST'] = 'unknown.example.com'
-        self.assertEqual(Site.find_for_request(request), default_site)
+        request.META['HTTP_HOST'] = self.unrecognised_hostname
+        request.META['SERVER_PORT'] = '80'
+        self.assertEqual(Site.find_for_request(request), self.default_site)
+
+    def test_unrecognised_port_and_default_host_routes_to_default_site(self):
+        # requests to the default host on an unrecognised port should be directed to the default site
+        request = HttpRequest()
+        request.path = '/'
+        request.META['HTTP_HOST'] = self.default_site.hostname
+        request.META['SERVER_PORT'] = self.unrecognised_port
+        self.assertEqual(Site.find_for_request(request), self.default_site)
+
+    def test_unrecognised_port_and_unrecognised_host_routes_to_default_site(self):
+        # requests with an unrecognised Host: header _and_ an unrecognised port
+        # hould be directed to the default site
+        request = HttpRequest()
+        request.path = '/'
+        request.META['HTTP_HOST'] = self.unrecognised_hostname
+        request.META['SERVER_PORT'] = self.unrecognised_port
+        self.assertEqual(Site.find_for_request(request), self.default_site)
+
+    def test_unrecognised_port_on_known_hostname_routes_there_if_no_ambiguity(self):
+        # requests on an unrecognised port should be directed to the site with
+        # matching hostname if there is no ambiguity
+        request = HttpRequest()
+        request.path = '/'
+        request.META['HTTP_HOST'] = self.about_site.hostname
+        request.META['SERVER_PORT'] = self.unrecognised_port
+        self.assertEqual(Site.find_for_request(request), self.about_site)
+
+    def test_unrecognised_port_on_known_hostname_routes_to_default_site_if_ambiguity(self):
+        # requests on an unrecognised port should be directed to the default
+        # site, even if their hostname (but not port) matches more than one
+        # other entry
+        request = HttpRequest()
+        request.path = '/'
+        request.META['HTTP_HOST'] = self.events_site.hostname
+        request.META['SERVER_PORT'] = self.unrecognised_port
+        self.assertEqual(Site.find_for_request(request), self.default_site)
+
+    def test_port_in_http_host_header_is_ignored(self):
+        # port in the HTTP_HOST header is ignored
+        request = HttpRequest()
+        request.path = '/'
+        request.META['HTTP_HOST'] = "%s:%s" % (self.events_site.hostname, self.events_site.port)
+        request.META['SERVER_PORT'] = self.alternate_port_events_site.port
+        self.assertEqual(Site.find_for_request(request), self.alternate_port_events_site)
+
+
+class TestRouting(TestCase):
+    fixtures = ['test.json']
 
     def test_urls(self):
         default_site = Site.objects.get(is_default_site=True)