Repository: wagtail/wagtail

Issue:
Site.hostname should be lowercase to prevent duplicates
### Issue Summary

Wagtail `Site.hostname` accepts duplicate hostnames. 
It possible to create `foo.com:80` and `Foo.com:80` but these are technically the same thus duplicates.

Site.hostname is case sensitive field. Hostnames and domain names are a case insensitive. https://tools.ietf.org/html/rfc4343 

`foo.com` and `Foo.com` should be treated as the same value and raise a validation error.

### Steps to Reproduce

1. Start a new project with `wagtail start myproject`
2. Go to Settings > Sites
3. Add site "foo.com" port 80
4. Add site "Foo.com" port 80

I expect a validation error: `Site with this Hostname and Port already exists.`

I added a clean method on wagtail.core.models.Site that  makes the hostname lowercase on save.
```
    def clean(self):
        self.hostname = self.hostname.lower()
```

The form raises an error now, but the error isn't displayed in the templates! 🐞
wagtail/admin/templates/wagtailadmin/generic/create.html
wagtail/admin/templates/wagtailadmin/generic/edit.html

These templates need `{{ form.non_field_errors }}`.

<img width="750" alt="Screenshot 2020-05-29 at 01 34 54" src="https://user-images.githubusercontent.com/1969342/83204661-a7060f00-a14c-11ea-8152-8568c0acef83.png">

* I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes


Discussion:

Resolving patch:
diff --git a/wagtail/models/sites.py b/wagtail/models/sites.py
--- a/wagtail/models/sites.py
+++ b/wagtail/models/sites.py
@@ -129,6 +129,9 @@ def __str__(self):
                 + (default_suffix if self.is_default_site else "")
             )
 
+    def clean(self):
+        self.hostname = self.hostname.lower()
+
     @staticmethod
     def find_for_request(request):
         """


Test patch:
diff --git a/wagtail/sites/tests.py b/wagtail/sites/tests.py
--- a/wagtail/sites/tests.py
+++ b/wagtail/sites/tests.py
@@ -129,6 +129,40 @@ def test_duplicate_hostnames_on_same_port_not_allowed(self):
         # Check that the site was not created, still only one localhost entry
         self.assertEqual(Site.objects.filter(hostname="localhost").count(), 1)
 
+    def test_duplicate_hostnames_case_variant_not_allowed(self):
+        # Confirm there's one localhost already
+        self.assertEqual(Site.objects.filter(hostname="localhost").count(), 1)
+
+        response = self.post(
+            {"hostname": "Localhost", "port": "80", "root_page": str(self.home_page.id)}
+        )
+
+        # Should return the form with errors
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.context["form"].errors,
+            {"__all__": ["Site with this Hostname and Port already exists."]},
+        )
+
+        # Check that the site was not created, still only one localhost entry
+        self.assertEqual(Site.objects.filter(hostname="localhost").count(), 1)
+
+    def test_non_field_errors_are_displayed(self):
+        self.assertEqual(Site.objects.filter(hostname="localhost").count(), 1)
+
+        response = self.post(
+            {"hostname": "localhost", "port": "80", "root_page": str(self.home_page.id)}
+        )
+        expected_html = """
+            <div class="help-block help-critical">
+                <svg class="icon icon-warning icon" aria-hidden="true">
+                    <use href="#icon-warning"></use>
+                </svg>
+                Site with this Hostname and Port already exists.
+            </div>
+        """
+        self.assertTagInHTML(expected_html, str(response.content))
+
 
 class TestSiteEditView(WagtailTestUtils, TestCase):
     def setUp(self):
@@ -247,6 +281,50 @@ def test_making_a_second_site_the_default_not_allowed(self):
 
         self.assertIs(Site.objects.get(id=second_site.id).is_default_site, False)
 
+    def test_duplicate_hostnames_case_variant_not_allowed(self):
+        second_site = Site.objects.create(
+            hostname="something_different",
+            port=80,
+            is_default_site=False,
+            root_page=self.home_page,
+        )
+        response = self.post(
+            {
+                "hostname": "Localhost",
+            },
+            site_id=second_site.id,
+        )
+
+        # Should return the form with errors
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.context["form"].errors,
+            {"__all__": ["Site with this Hostname and Port already exists."]},
+        )
+
+    def test_non_field_errors_are_displayed(self):
+        second_site = Site.objects.create(
+            hostname="something_different",
+            port=80,
+            is_default_site=False,
+            root_page=self.home_page,
+        )
+        response = self.post(
+            {
+                "hostname": "Localhost",
+            },
+            site_id=second_site.id,
+        )
+        expected_html = """
+            <div class="help-block help-critical">
+                <svg class="icon icon-warning icon" aria-hidden="true">
+                    <use href="#icon-warning"></use>
+                </svg>
+                Site with this Hostname and Port already exists.
+            </div>
+        """
+        self.assertTagInHTML(expected_html, str(response.content))
+
 
 class TestSiteDeleteView(WagtailTestUtils, TestCase):
     def setUp(self):