diff --git a/frontend_tests/casper_lib/common.js b/frontend_tests/casper_lib/common.js
index e9074efdf7..80eb25cacb 100644
--- a/frontend_tests/casper_lib/common.js
+++ b/frontend_tests/casper_lib/common.js
@@ -1,3 +1,4 @@
+var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
var common = (function () {
var exports = {};
@@ -83,7 +84,13 @@ exports.then_log_in = function (credentials) {
};
exports.start_and_log_in = function (credentials, viewport) {
- casper.start('http://127.0.0.1:9981/accounts/login', function () {
+ var log_in_url = "";
+ if (REALMS_HAVE_SUBDOMAINS) {
+ log_in_url = "http://zulip.zulipdev.com:9981/accounts/login";
+ } else {
+ log_in_url = "http://localhost:9981/accounts/login";
+ }
+ casper.start(log_in_url, function () {
exports.initialize_casper(viewport);
log_in(credentials);
});
diff --git a/frontend_tests/casper_tests/00-realm-creation.js b/frontend_tests/casper_tests/00-realm-creation.js
index b64ba1a7ba..2c23bcba20 100644
--- a/frontend_tests/casper_tests/00-realm-creation.js
+++ b/frontend_tests/casper_tests/00-realm-creation.js
@@ -2,9 +2,20 @@ var common = require('../casper_lib/common.js').common;
var email = 'alice@test.example.com';
var domain = 'test.example.com';
+var subdomain = 'testsubdomain';
var organization_name = 'Awesome Organization';
+var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
+var host;
+var realm_host;
-casper.start('http://127.0.0.1:9981/create_realm/');
+if (REALMS_HAVE_SUBDOMAINS) {
+ host = 'zulipdev.com:9981';
+ realm_host = subdomain + '.' + host;
+} else {
+ host = realm_host = 'localhost:9981';
+}
+
+casper.start('http://' + host + '/create_realm/');
casper.then(function () {
// Submit the email for realm creation
@@ -21,12 +32,12 @@ casper.then(function () {
});
// Special endpoint enabled only during tests for extracting confirmation key
-casper.thenOpen('http://127.0.0.1:9981/confirmation_key/');
+casper.thenOpen('http://' + host + '/confirmation_key/');
// Open the confirmation URL
casper.then(function () {
var confirmation_key = JSON.parse(this.getPageContent()).confirmation_key;
- var confirmation_url = 'http://127.0.0.1:9981/accounts/do_confirm/' + confirmation_key;
+ var confirmation_url = 'http://' + host + '/accounts/do_confirm/' + confirmation_key;
this.thenOpen(confirmation_url);
});
@@ -47,16 +58,26 @@ casper.then(function () {
casper.then(function () {
this.waitForSelector('form[action^="/accounts/register/"]', function () {
- this.fill('form[action^="/accounts/register/"]', {
- full_name: 'Alice',
- realm_name: organization_name,
- password: 'password',
- terms: true
- }, true);
+ if (REALMS_HAVE_SUBDOMAINS) {
+ this.fill('form[action^="/accounts/register/"]', {
+ full_name: 'Alice',
+ realm_name: organization_name,
+ realm_subdomain: subdomain,
+ password: 'password',
+ terms: true
+ }, true);
+ } else {
+ this.fill('form[action^="/accounts/register/"]', {
+ full_name: 'Alice',
+ realm_name: organization_name,
+ password: 'password',
+ terms: true
+ }, true);
+ }
});
this.waitWhileSelector('form[action^="/accounts/register/"]', function () {
- casper.test.assertUrlMatch('http://127.0.0.1:9981/invite/', 'Invite more users page loaded');
+ casper.test.assertUrlMatch(realm_host + '/invite/', 'Invite more users page loaded');
});
});
@@ -75,7 +96,7 @@ casper.then(function () {
});
this.waitWhileSelector('#submit_invitation', function () {
- this.test.assertUrlMatch('http://127.0.0.1:9981/', 'Realm created and logged in');
+ this.test.assertUrlMatch(realm_host, 'Realm created and logged in');
});
});
diff --git a/frontend_tests/casper_tests/01-login.js b/frontend_tests/casper_tests/01-login.js
index b0a3489469..2ca6d954e4 100644
--- a/frontend_tests/casper_tests/01-login.js
+++ b/frontend_tests/casper_tests/01-login.js
@@ -1,7 +1,14 @@
var common = require('../casper_lib/common.js').common;
+var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
+var realm_url = "";
+if (REALMS_HAVE_SUBDOMAINS) {
+ realm_url = "http://zulip.zulipdev.com:9981/";
+} else {
+ realm_url = "http://localhost:9981/";
+}
// Start of test script.
-casper.start('http://127.0.0.1:9981/', common.initialize_casper);
+casper.start(realm_url, common.initialize_casper);
casper.then(function () {
casper.test.assertHttpStatus(302);
diff --git a/frontend_tests/casper_tests/06-settings.js b/frontend_tests/casper_tests/06-settings.js
index dc3b5bb853..8b7b4f34ef 100644
--- a/frontend_tests/casper_tests/06-settings.js
+++ b/frontend_tests/casper_tests/06-settings.js
@@ -1,5 +1,6 @@
var common = require('../casper_lib/common.js').common;
var test_credentials = require('../../var/casper/test_credentials.js').test_credentials;
+var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
common.start_and_log_in();
@@ -166,7 +167,14 @@ casper.waitForSelector("#default_language", function () {
casper.test.info("Opening German page through i18n url.");
});
-casper.thenOpen('http://127.0.0.1:9981/de/#settings');
+var settings_url = "";
+if (REALMS_HAVE_SUBDOMAINS) {
+ settings_url = 'http://zulip.zulipdev.com:9981/de/#settings';
+} else {
+ settings_url = 'http://localhost:9981/de/#settings';
+}
+
+casper.thenOpen(settings_url);
casper.waitForSelector("#settings-change-box", function check_url_preference() {
casper.test.info("Checking the i18n url language precedence.");
diff --git a/frontend_tests/casper_tests/10-admin.js b/frontend_tests/casper_tests/10-admin.js
index f2c96842c2..79c65bea90 100644
--- a/frontend_tests/casper_tests/10-admin.js
+++ b/frontend_tests/casper_tests/10-admin.js
@@ -144,7 +144,7 @@ casper.then(function () {
casper.waitForSelector('.admin-emoji-form', function () {
casper.fill('form.admin-emoji-form', {
'name': 'MouseFace',
- 'url': 'http://127.0.0.1:9991/static/images/integrations/logos/jenkins.png'
+ 'url': 'http://localhost:9991/static/images/integrations/logos/jenkins.png'
});
casper.click('form.admin-emoji-form input.button');
});
@@ -159,7 +159,7 @@ casper.then(function () {
casper.then(function () {
casper.waitForSelector('.emoji_row', function () {
casper.test.assertSelectorHasText('.emoji_row .emoji_name', 'MouseFace');
- casper.test.assertExists('.emoji_row img[src="http://127.0.0.1:9991/static/images/integrations/logos/jenkins.png"]');
+ casper.test.assertExists('.emoji_row img[src="http://localhost:9991/static/images/integrations/logos/jenkins.png"]');
casper.click('.emoji_row button.delete');
});
});
diff --git a/frontend_tests/run-casper b/frontend_tests/run-casper
index 6f158a1682..00f7f6f5f7 100755
--- a/frontend_tests/run-casper
+++ b/frontend_tests/run-casper
@@ -64,8 +64,8 @@ def server_is_up(server):
except:
return False
-def run_tests(files):
- # type: (Iterable[str]) -> None
+def run_tests(realms_have_subdomains, files):
+ # type: (bool, Iterable[str]) -> None
test_files = []
for file in files:
if not os.path.exists(file):
@@ -78,7 +78,7 @@ def run_tests(files):
if options.remote_debug:
remote_debug = "--remote-debugger-port=7777 --remote-debugger-autorun=yes"
- cmd = "frontend_tests/casperjs/bin/casperjs %s test " % (remote_debug,)
+ cmd = "frontend_tests/casperjs/bin/casperjs %s test --subdomains=%s " % (remote_debug, realms_have_subdomains,)
if test_files:
cmd += ' '.join(test_files)
else:
@@ -115,5 +115,13 @@ Oops, the frontend tests failed. Tips for debugging:
sys.exit(ret)
-run_tests(args)
+# Run tests with REALMS_HAVE_SUBDOMAINS set to True
+run_tests(False, args)
+os.environ["EXTERNAL_HOST"] = "zulipdev.com:9981"
+os.environ["REALMS_HAVE_SUBDOMAINS"] = "True"
+# Run tests with REALMS_HAVE_SUBDOMAINS set to True
+if len(args) == 0:
+ run_tests(True, ["00-realm-creation.js", "01-login.js", "02-site.js"])
+else:
+ run_tests(True, args)
sys.exit(0)
diff --git a/templates/zerver/invalid_realm.html b/templates/zerver/invalid_realm.html
new file mode 100644
index 0000000000..f8ca55b783
--- /dev/null
+++ b/templates/zerver/invalid_realm.html
@@ -0,0 +1,9 @@
+{% extends "zerver/portico.html" %}
+{% block portico_content %}
+
+
{{ _('Organization Does Not Exist') }}
+
+
{{ _('Hi there! Thank you for your interest in Zulip') }}.
+
{% trans %}There is no Zulip organization hosted at this subdomain{% endtrans %}.
+
+{% endblock %}
diff --git a/templates/zerver/register.html b/templates/zerver/register.html
index 059a48cbb5..f31dd02fd0 100644
--- a/templates/zerver/register.html
+++ b/templates/zerver/register.html
@@ -33,6 +33,7 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
+
@@ -51,7 +52,7 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
- {% if creating_new_team %}
+ {% if creating_new_team %}
@@ -65,7 +66,24 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
{% endif %}
{{ _('You can change this later on the admin page.') }}
+
+ {% if realms_have_subdomains %}
+
+
+
+ .{{ external_host }}
+ {% if form.realm_subdomain.errors %}
+ {% for error in form.realm_subdomain.errors %}
+
{{ error }}
+ {% endfor %}
+ {% endif %}
+
{% trans %}The address you'll use to sign in to your organization.{% endtrans %}.
+
+ {% endif %}
+
{% endif %}
{% if password_auth_enabled %}
diff --git a/zerver/decorator.py b/zerver/decorator.py
index e6707cf700..5b9b06205b 100644
--- a/zerver/decorator.py
+++ b/zerver/decorator.py
@@ -14,7 +14,7 @@ from django.utils.timezone import now
from django.conf import settings
from zerver.lib.queue import queue_json_publish
from zerver.lib.timestamp import datetime_to_timestamp
-from zerver.lib.utils import statsd
+from zerver.lib.utils import statsd, get_subdomain, check_subdomain
from zerver.exceptions import RateLimited
from zerver.lib.rate_limiter import incr_ratelimit, is_ratelimited, \
api_calls_left
@@ -279,7 +279,7 @@ def logged_in_and_active(request):
return False
if request.user.realm.deactivated:
return False
- return True
+ return check_subdomain(get_subdomain(request), request.user.realm.subdomain)
# Based on Django 1.8's @login_required
def zulip_login_required(function=None,
diff --git a/zerver/forms.py b/zerver/forms.py
index b387d9c724..21e216ad04 100644
--- a/zerver/forms.py
+++ b/zerver/forms.py
@@ -10,6 +10,8 @@ from django.db.models.query import QuerySet
from jinja2 import Markup as mark_safe
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
+from zerver.models import resolve_subdomain_to_realm
+from zerver.lib.utils import get_subdomain, check_subdomain
import logging
@@ -23,6 +25,11 @@ from six import text_type
SIGNUP_STRING = u'Your e-mail does not match any existing open organization. ' + \
u'Use a different e-mail address, or contact %s with questions.' % (settings.ZULIP_ADMINISTRATOR,)
+
+def subdomain_unavailable(subdomain):
+ # type: (text_type) -> text_type
+ return u"The subdomain '%s' is not available. Please choose another one." % (subdomain)
+
if settings.SHOW_OSS_ANNOUNCEMENT:
SIGNUP_STRING = u'Your e-mail does not match any existing organization. ' + \
u"The zulip.com service is not taking new customer teams. " + \
@@ -32,7 +39,10 @@ if settings.SHOW_OSS_ANNOUNCEMENT:
MIT_VALIDATION_ERROR = u'That user does not exist at MIT or is a ' + \
u'mailing list. ' + \
u'If you want to sign up an alias for Zulip, ' + \
- u'contact us.'
+ u'contact us.'
+WRONG_SUBDOMAIN_ERROR = "Your Zulip account is not a member of the " + \
+ "organization associated with this subdomain. " + \
+ "Please contact %s with any questions!" % (settings.ZULIP_ADMINISTRATOR,)
def get_registration_string(domain):
# type: (text_type) -> text_type
@@ -73,10 +83,18 @@ class RegistrationForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput, max_length=100,
required=False)
realm_name = forms.CharField(max_length=100, required=False)
-
+ realm_subdomain = forms.CharField(max_length=40, required=False)
if settings.TERMS_OF_SERVICE:
terms = forms.BooleanField(required=True)
+ def clean_realm_subdomain(self):
+ # type: () -> str
+ data = self.cleaned_data['realm_subdomain']
+ realm = resolve_subdomain_to_realm(data)
+ if realm is not None:
+ raise ValidationError(subdomain_unavailable(data))
+ return data
+
class ToSForm(forms.Form):
terms = forms.BooleanField(required=True)
@@ -89,8 +107,11 @@ class HomepageForm(forms.Form):
def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None
self.domain = kwargs.get("domain")
+ self.subdomain = kwargs.get("subdomain")
if "domain" in kwargs:
del kwargs["domain"]
+ if "subdomain" in kwargs:
+ del kwargs["subdomain"]
super(HomepageForm, self).__init__(*args, **kwargs)
def clean_email(self):
@@ -106,6 +127,12 @@ class HomepageForm(forms.Form):
if completely_open(self.domain):
return data
+ # If the subdomain encodes a complete open realm, pass
+ subdomain_realm = resolve_subdomain_to_realm(self.subdomain)
+ if (subdomain_realm is not None and
+ completely_open(subdomain_realm.domain)):
+ return data
+
# If no realm is specified, fail
realm = get_valid_realm(data)
if realm is None:
@@ -191,4 +218,8 @@ Please contact %s to reactivate this group.""" % (
settings.ZULIP_ADMINISTRATOR)
raise ValidationError(mark_safe(error_msg))
+ if not check_subdomain(get_subdomain(self.request), user_profile.realm.subdomain):
+ logging.warning("User %s attempted to password login to wrong subdomain %s" %
+ (user_profile.email, get_subdomain(self.request)))
+ raise ValidationError(mark_safe(WRONG_SUBDOMAIN_ERROR))
return email
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
index 730d8e7c98..4e218c9369 100644
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -1929,12 +1929,12 @@ def do_change_stream_description(realm, stream_name, new_description):
value=new_description)
send_event(event, stream_user_ids(stream))
-def do_create_realm(domain, name, restricted_to_domain=True):
- # type: (text_type, text_type, bool) -> Tuple[Realm, bool]
+def do_create_realm(domain, name, restricted_to_domain=True, subdomain=None):
+ # type: (text_type, text_type, bool, Optional[text_type]) -> Tuple[Realm, bool]
realm = get_realm(domain)
created = not realm
if created:
- realm = Realm(domain=domain, name=name,
+ realm = Realm(domain=domain, name=name, subdomain=subdomain,
restricted_to_domain=restricted_to_domain)
realm.save()
diff --git a/zerver/lib/test_helpers.py b/zerver/lib/test_helpers.py
index 79631af26e..3141380cb8 100644
--- a/zerver/lib/test_helpers.py
+++ b/zerver/lib/test_helpers.py
@@ -384,18 +384,24 @@ class ZulipTestCase(TestCase):
{'email': username + "@" + domain})
return self.submit_reg_form_for_user(username, password, domain=domain)
- def submit_reg_form_for_user(self, username, password, domain="zulip.com"):
- # type: (text_type, text_type, text_type) -> HttpResponse
+ def submit_reg_form_for_user(self, username, password, domain="zulip.com",
+ realm_name=None, realm_subdomain=None, **kwargs):
+ # type: (text_type, text_type, text_type, Optional[text_type], Optional[text_type], **Any) -> HttpResponse
"""
Stage two of the two-step registration process.
If things are working correctly the account should be fully
registered after this call.
+
+ You can pass the HTTP_HOST variable for subdomains via kwargs.
"""
return self.client_post('/accounts/register/',
{'full_name': username, 'password': password,
+ 'realm_name': realm_name,
+ 'realm_subdomain': realm_subdomain,
'key': find_key_by_email(username + '@' + domain),
- 'terms': True})
+ 'terms': True},
+ **kwargs)
def get_confirmation_url_from_outbox(self, email_address, path_pattern="(\S+)>"):
# type: (text_type, text_type) -> text_type
diff --git a/zerver/lib/utils.py b/zerver/lib/utils.py
index 98629813e3..d8c140a452 100644
--- a/zerver/lib/utils.py
+++ b/zerver/lib/utils.py
@@ -13,6 +13,7 @@ import os
from time import sleep
from django.conf import settings
+from django.http import HttpRequest
from six.moves import range
from zerver.lib.str_utils import force_text
@@ -184,3 +185,21 @@ def query_chunker(queries, id_collector=None, chunk_size=1000, db_chunk_size=Non
id_collector.update(tup_ids)
yield [row for row_id, i, row in tup_chunk]
+
+def get_subdomain(request):
+ # type: (HttpRequest) -> text_type
+ domain = request.get_host().lower()
+ index = domain.find("." + settings.EXTERNAL_HOST)
+ if index == -1:
+ return ""
+ subdomain = domain[0:index]
+ return subdomain
+
+def check_subdomain(realm_subdomain, user_subdomain):
+ # type: (text_type, text_type) -> bool
+ if settings.REALMS_HAVE_SUBDOMAINS and realm_subdomain is not None:
+ if (realm_subdomain == "" and user_subdomain is None):
+ return True
+ if realm_subdomain != user_subdomain:
+ return False
+ return True
diff --git a/zerver/middleware.py b/zerver/middleware.py
index e812c417e7..d8de1d7b76 100644
--- a/zerver/middleware.py
+++ b/zerver/middleware.py
@@ -10,16 +10,18 @@ from zerver.lib.response import json_error
from zerver.lib.request import JsonableError
from django.db import connection
from django.http import HttpRequest, HttpResponse
-from zerver.lib.utils import statsd
+from zerver.lib.utils import statsd, get_subdomain
from zerver.lib.queue import queue_json_publish
from zerver.lib.cache import get_remote_cache_time, get_remote_cache_requests
from zerver.lib.bugdown import get_bugdown_time, get_bugdown_requests
-from zerver.models import flush_per_request_caches
+from zerver.models import flush_per_request_caches, resolve_subdomain_to_realm
from zerver.exceptions import RateLimited
from django.contrib.sessions.middleware import SessionMiddleware
from django.views.csrf import csrf_failure as html_csrf_failure
from django.utils.cache import patch_vary_headers
from django.utils.http import cookie_date
+from zproject.jinja2 import render_to_response
+from django.shortcuts import redirect
import logging
import time
@@ -346,6 +348,17 @@ class FlushDisplayRecipientCache(object):
class SessionHostDomainMiddleware(SessionMiddleware):
def process_response(self, request, response):
# type: (HttpRequest, HttpResponse) -> HttpResponse
+ if settings.REALMS_HAVE_SUBDOMAINS:
+ if (not request.path.startswith("/static/") and not request.path.startswith("/api/")
+ and not request.path.startswith("/json/")):
+ subdomain = get_subdomain(request)
+ if (request.get_host() == "127.0.0.1:9991" or request.get_host() == "localhost:9991"):
+ return redirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
+ settings.EXTERNAL_HOST))
+ if subdomain != "":
+ realm = resolve_subdomain_to_realm(subdomain)
+ if (realm is None):
+ return render_to_response("zerver/invalid_realm.html")
"""
If request.session was modified, or if the configuration is to save the
session every time, save the changes and set a session cookie.
@@ -371,9 +384,15 @@ class SessionHostDomainMiddleware(SessionMiddleware):
if response.status_code != 500:
request.session.save()
host = request.get_host().split(':')[0]
+
session_cookie_domain = settings.SESSION_COOKIE_DOMAIN
- if host.endswith(".e.zulip.com"):
- session_cookie_domain = ".e.zulip.com"
+ # The subdomains feature overrides the
+ # SESSION_COOKIE_DOMAIN setting, since the setting
+ # is a fixed value and with subdomains enabled,
+ # the session cookie domain has to vary with the
+ # subdomain.
+ if settings.REALMS_HAVE_SUBDOMAINS:
+ session_cookie_domain = host
response.set_cookie(settings.SESSION_COOKIE_NAME,
request.session.session_key, max_age=max_age,
expires=expires, domain=session_cookie_domain,
diff --git a/zerver/migrations/0029_realm_subdomain.py b/zerver/migrations/0029_realm_subdomain.py
new file mode 100644
index 0000000000..b5f4efa1f9
--- /dev/null
+++ b/zerver/migrations/0029_realm_subdomain.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
+
+def set_subdomain_of_default_realm(apps, schema_editor):
+ # type: (StateApps, DatabaseSchemaEditor) -> None
+ if settings.DEVELOPMENT:
+ Realm = apps.get_model('zerver', 'Realm')
+ try:
+ default_realm = Realm.objects.get(domain="zulip.com")
+ except ObjectDoesNotExist:
+ default_realm = None
+
+ if default_realm is not None:
+ default_realm.subdomain = "zulip"
+ default_realm.save()
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('zerver', '0028_userprofile_tos_version'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='realm',
+ name='subdomain',
+ field=models.CharField(max_length=40, unique=True, null=True),
+ ),
+ migrations.RunPython(set_subdomain_of_default_realm)
+ ]
diff --git a/zerver/models.py b/zerver/models.py
index 89cb0453b4..54d1840f08 100644
--- a/zerver/models.py
+++ b/zerver/models.py
@@ -140,6 +140,7 @@ class Realm(ModelReprMixin, models.Model):
# name is the user-visible identifier for the realm. It has no required
# structure.
name = models.CharField(max_length=40, null=True) # type: Optional[text_type]
+ subdomain = models.CharField(max_length=40, null=True, unique=True) # type: Optional[text_type]
restricted_to_domain = models.BooleanField(default=True) # type: bool
invite_required = models.BooleanField(default=False) # type: bool
invite_by_admins_only = models.BooleanField(default=False) # type: bool
@@ -199,11 +200,16 @@ class Realm(ModelReprMixin, models.Model):
@property
def uri(self):
# type: () -> str
+ if settings.REALMS_HAVE_SUBDOMAINS and self.subdomain is not None:
+ return '%s%s.%s' % (settings.EXTERNAL_URI_SCHEME,
+ self.subdomain, settings.EXTERNAL_HOST)
return settings.SERVER_URI
@property
def host(self):
# type: () -> str
+ if settings.REALMS_HAVE_SUBDOMAINS and self.subdomain is not None:
+ return "%s.%s" % (self.subdomain, settings.EXTERNAL_HOST)
return settings.EXTERNAL_HOST
@property
@@ -258,6 +264,13 @@ def resolve_email_to_domain(email):
domain = alias.realm.domain
return domain
+def resolve_subdomain_to_realm(subdomain):
+ # type: (text_type) -> Optional[Realm]
+ try:
+ return Realm.objects.get(subdomain=subdomain)
+ except Realm.DoesNotExist:
+ return None
+
# Is a user with the given email address allowed to be in the given realm?
# (This function does not check whether the user has been invited to the realm.
# So for invite-only realms, this is the test for whether a user can be invited,
diff --git a/zerver/tests/test_integrations.py b/zerver/tests/test_integrations.py
index 4bd53dc91b..039d27d106 100644
--- a/zerver/tests/test_integrations.py
+++ b/zerver/tests/test_integrations.py
@@ -3,7 +3,8 @@ from __future__ import absolute_import
import os
-from django.test import TestCase
+from django.conf import settings
+from django.test import TestCase, override_settings
from typing import Any
from zproject.settings import DEPLOY_ROOT
@@ -17,9 +18,27 @@ class IntegrationTest(TestCase):
for integration in INTEGRATIONS.values():
self.assertTrue(os.path.isfile(os.path.join(DEPLOY_ROOT, integration.logo)))
+ @override_settings(REALMS_HAVE_SUBDOMAINS=False)
def test_api_url_view_base(self):
# type: () -> None
context = dict() # type: Dict[str, Any]
add_api_uri_context(context, HostRequestMock())
- self.assertEqual(context["external_api_path_subdomain"], "localhost:9991/api")
- self.assertEqual(context["external_api_uri_subdomain"], "http://localhost:9991/api")
+ self.assertEqual(context["external_api_path_subdomain"], "zulipdev.com:9991/api")
+ self.assertEqual(context["external_api_uri_subdomain"], "http://zulipdev.com:9991/api")
+
+ @override_settings(REALMS_HAVE_SUBDOMAINS=True)
+ def test_api_url_view_subdomains_base(self):
+ # type: () -> None
+ context = dict() # type: Dict[str, Any]
+ add_api_uri_context(context, HostRequestMock())
+ self.assertEqual(context["external_api_path_subdomain"], "yourZulipDomain.zulipdev.com:9991/api")
+ self.assertEqual(context["external_api_uri_subdomain"], "http://yourZulipDomain.zulipdev.com:9991/api")
+
+ @override_settings(REALMS_HAVE_SUBDOMAINS=True, EXTERNAL_HOST="zulipdev.com")
+ def test_api_url_view_subdomains_full(self):
+ # type: () -> None
+ context = dict() # type: Dict[str, Any]
+ request = HostRequestMock(host="mysubdomain.zulipdev.com")
+ add_api_uri_context(context, request)
+ self.assertEqual(context["external_api_path_subdomain"], "mysubdomain.zulipdev.com:9991/api")
+ self.assertEqual(context["external_api_uri_subdomain"], "http://mysubdomain.zulipdev.com:9991/api")
diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py
index 8b23b0bc78..a1a32282e9 100644
--- a/zerver/tests/test_signup.py
+++ b/zerver/tests/test_signup.py
@@ -734,3 +734,54 @@ class UserSignUpTest(ZulipTestCase):
self.assertEqual(user_profile.default_language, realm.default_language)
from django.core.mail import outbox
outbox.pop()
+
+ def test_create_realm_with_subdomain(self):
+ # type: () -> None
+ username = "user1"
+ password = "test"
+ domain = "test.com"
+ email = "user1@test.com"
+ subdomain = "test"
+ realm_name = "Test"
+
+ # Make sure the realm does not exist
+ self.assertIsNone(get_realm(domain))
+ with self.settings(REALMS_HAVE_SUBDOMAINS=True), self.settings(OPEN_REALM_CREATION=True):
+ # Create new realm with the email
+ result = self.client_post('/create_realm/', {'email': email})
+ self.assertEquals(result.status_code, 302)
+ self.assertTrue(result["Location"].endswith(
+ "/accounts/send_confirm/%s@%s" % (username, domain)))
+ result = self.client_get(result["Location"])
+ self.assert_in_response("Check your email so we can get started.", result)
+ # Visit the confirmation link.
+ from django.core.mail import outbox
+ for message in reversed(outbox):
+ if email in message.to:
+ confirmation_link_pattern = re.compile(settings.EXTERNAL_HOST + "(\S+)>")
+ confirmation_url = confirmation_link_pattern.search(
+ message.body).groups()[0]
+ break
+ else:
+ raise ValueError("Couldn't find a confirmation email.")
+
+ result = self.client_get(confirmation_url)
+ self.assertEquals(result.status_code, 200)
+
+ result = self.submit_reg_form_for_user(username,
+ password,
+ domain=domain,
+ realm_name=realm_name,
+ realm_subdomain=subdomain,
+ # Pass HTTP_HOST for the target subdomain
+ HTTP_HOST=subdomain + ".testserver")
+ self.assertEquals(result.status_code, 302)
+
+ # Make sure the realm is created
+ realm = get_realm(domain)
+
+ self.assertIsNotNone(realm)
+ self.assertEqual(realm.domain, domain)
+ self.assertEqual(realm.name, realm_name)
+ self.assertEqual(realm.subdomain, subdomain)
+ self.assertEqual(get_user_profile_by_email(email).realm, realm)
diff --git a/zerver/views/__init__.py b/zerver/views/__init__.py
index 2f225fe5c5..d07da7e428 100644
--- a/zerver/views/__init__.py
+++ b/zerver/views/__init__.py
@@ -25,7 +25,7 @@ from zerver.models import Message, UserProfile, Stream, Subscription, Huddle, \
get_stream, UserPresence, get_recipient, \
split_email_to_domain, resolve_email_to_domain, email_to_username, get_realm, \
completely_open, get_unique_open_realm, remote_user_to_email, email_allowed_for_realm, \
- get_cross_realm_users
+ get_cross_realm_users, resolve_subdomain_to_realm
from zerver.lib.actions import do_change_password, do_change_full_name, do_change_is_admin, \
do_activate_user, do_create_user, do_create_realm, set_default_streams, \
internal_send_message, update_user_presence, do_events_register, \
@@ -49,7 +49,7 @@ from zerver.lib.avatar import avatar_url
from zerver.lib.i18n import get_language_list, get_language_name, \
get_language_list_for_templates
from zerver.lib.response import json_success, json_error
-from zerver.lib.utils import statsd
+from zerver.lib.utils import statsd, get_subdomain
from version import ZULIP_VERSION
from zproject.backends import password_auth_enabled, dev_auth_enabled, google_auth_enabled
@@ -198,7 +198,12 @@ def accounts_register(request):
if realm_creation:
domain = split_email_to_domain(email)
- realm = do_create_realm(domain, form.cleaned_data['realm_name'])[0]
+ if settings.REALMS_HAVE_SUBDOMAINS:
+ realm = do_create_realm(domain, form.cleaned_data['realm_name'],
+ subdomain=form.cleaned_data['realm_subdomain'])[0]
+ else:
+ realm = do_create_realm(domain, form.cleaned_data['realm_name'])[0]
+
set_default_streams(realm, settings.DEFAULT_NEW_REALM_STREAMS)
full_name = form.cleaned_data['full_name']
@@ -225,11 +230,21 @@ def accounts_register(request):
# This logs you in using the ZulipDummyBackend, since honestly nothing
# more fancy than this is required.
- login(request, authenticate(username=user_profile.email, use_dummy_backend=True))
+ login(request, authenticate(username=user_profile.email,
+ realm_subdomain=realm.subdomain,
+ use_dummy_backend=True))
if first_in_realm:
do_change_is_admin(user_profile, True)
- return HttpResponseRedirect(reverse('zerver.views.initial_invite_page'))
+ invite_url = reverse('zerver.views.initial_invite_page')
+ if (realm_creation and settings.REALMS_HAVE_SUBDOMAINS):
+ invite_url = "%s%s.%s%s" % (
+ settings.EXTERNAL_URI_SCHEME,
+ form.cleaned_data['realm_subdomain'],
+ settings.EXTERNAL_HOST,
+ reverse('zerver.views.initial_invite_page')
+ )
+ return HttpResponseRedirect(invite_url)
else:
return HttpResponseRedirect(reverse('zerver.views.home'))
@@ -244,6 +259,7 @@ def accounts_register(request):
# but for the registration form, there is no logged in user yet, so
# we have to set it here.
'creating_new_team': realm_creation,
+ 'realms_have_subdomains': settings.REALMS_HAVE_SUBDOMAINS,
'password_auth_enabled': password_auth_enabled(realm),
},
request=request)
@@ -316,10 +332,11 @@ def get_invitee_emails_set(invitee_emails_raw):
def create_homepage_form(request, user_info=None):
# type: (HttpRequest, Optional[Dict[str, Any]]) -> HomepageForm
if user_info:
- return HomepageForm(user_info, domain=request.session.get("domain"))
+ return HomepageForm(user_info, domain=request.session.get("domain"),
+ subdomain=get_subdomain(request))
# An empty fields dict is not treated the same way as not
# providing it.
- return HomepageForm(domain=request.session.get("domain"))
+ return HomepageForm(domain=request.session.get("domain"), subdomain=get_subdomain(request))
def maybe_send_to_registration(request, email, full_name=''):
# type: (HttpRequest, text_type, text_type) -> HttpResponse
@@ -362,6 +379,11 @@ def login_or_register_remote_user(request, remote_username, user_profile, full_n
return maybe_send_to_registration(request, remote_user_to_email(remote_username), full_name)
else:
login(request, user_profile)
+ if settings.OPEN_REALM_CREATION and user_profile.realm.subdomain is not None:
+ return HttpResponseRedirect("%s%s.%s" % (settings.EXTERNAL_URI_SCHEME,
+ user_profile.realm.subdomain,
+ settings.EXTERNAL_HOST))
+
return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
request.get_host()))
@@ -372,7 +394,7 @@ def remote_user_sso(request):
except KeyError:
raise JsonableError(_("No REMOTE_USER set."))
- user_profile = authenticate(remote_user=remote_user)
+ user_profile = authenticate(remote_user=remote_user, realm_subdomain=get_subdomain(request))
return login_or_register_remote_user(request, remote_user, user_profile)
@csrf_exempt
@@ -401,7 +423,9 @@ def remote_user_jwt(request):
# We do all the authentication we need here (otherwise we'd have to
# duplicate work), but we need to call authenticate with some backend so
# that the request.backend attribute gets set.
- user_profile = authenticate(username=email, use_dummy_backend=True)
+ user_profile = authenticate(username=email,
+ realm_subdomain=get_subdomain(request),
+ use_dummy_backend=True)
except (jwt.DecodeError, jwt.ExpiredSignature):
raise JsonableError(_("Bad JSON web token signature"))
except KeyError:
@@ -503,7 +527,9 @@ def finish_google_oauth2(request):
logging.error('Google oauth2 account email not found: %s' % (body,))
return HttpResponse(status=400)
email_address = email['value']
- user_profile = authenticate(username=email_address, use_dummy_backend=True)
+ user_profile = authenticate(username=email_address,
+ realm_subdomain=get_subdomain(request),
+ use_dummy_backend=True)
return login_or_register_remote_user(request, email_address, user_profile, full_name)
def login_page(request, **kwargs):
@@ -536,10 +562,16 @@ def dev_direct_login(request, **kwargs):
# This check is probably not required, since authenticate would fail without an enabled DevAuthBackend.
raise Exception('Direct login not supported.')
email = request.POST['direct_email']
- user_profile = authenticate(username=email)
+ user_profile = authenticate(username=email, realm_subdomain=get_subdomain(request))
if user_profile is None:
raise Exception("User cannot login")
login(request, user_profile)
+ if settings.OPEN_REALM_CREATION and settings.DEVELOPMENT:
+ if user_profile.realm.subdomain is not None:
+ return HttpResponseRedirect("%s%s.%s" % (settings.EXTERNAL_URI_SCHEME,
+ user_profile.realm.subdomain,
+ settings.EXTERNAL_HOST))
+
return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
request.get_host()))
@@ -555,7 +587,9 @@ def api_dev_fetch_api_key(request, username=REQ()):
if not dev_auth_enabled() or settings.PRODUCTION:
return json_error(_("Dev environment not enabled."))
return_data = {} # type: Dict[str, bool]
- user_profile = authenticate(username=username, return_data=return_data)
+ user_profile = authenticate(username=username,
+ realm_subdomain=get_subdomain(request),
+ return_data=return_data)
if return_data.get("inactive_realm") == True:
return json_error(_("Your realm has been deactivated."),
data={"reason": "realm deactivated"}, status=403)
@@ -660,7 +694,8 @@ def send_registration_completion_email(email, request, realm_creation=False):
context = {'support_email': settings.ZULIP_ADMINISTRATOR,
'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS}
return Confirmation.objects.send_confirmation(prereg_user, email,
- additional_context=context)
+ additional_context=context,
+ host=request.get_host())
def redirect_to_email_login_url(email):
# type: (str) -> HttpResponseRedirect
@@ -1003,9 +1038,14 @@ def api_fetch_api_key(request, username=REQ(), password=REQ()):
# type: (HttpRequest, str, str) -> HttpResponse
return_data = {} # type: Dict[str, bool]
if username == "google-oauth2-token":
- user_profile = authenticate(google_oauth2_token=password, return_data=return_data)
+ user_profile = authenticate(google_oauth2_token=password,
+ realm_subdomain=get_subdomain(request),
+ return_data=return_data)
else:
- user_profile = authenticate(username=username, password=password, return_data=return_data)
+ user_profile = authenticate(username=username,
+ password=password,
+ realm_subdomain=get_subdomain(request),
+ return_data=return_data)
if return_data.get("inactive_user") == True:
return json_error(_("Your account has been disabled."),
data={"reason": "user disable"}, status=403)
@@ -1039,7 +1079,8 @@ def api_get_auth_backends(request):
def json_fetch_api_key(request, user_profile, password=REQ(default='')):
# type: (HttpRequest, UserProfile, str) -> HttpResponse
if password_auth_enabled(user_profile.realm):
- if not authenticate(username=user_profile.email, password=password):
+ if not authenticate(username=user_profile.email, password=password,
+ realm_subdomain=get_subdomain(request)):
return json_error(_("Your username or password is incorrect."))
return json_success({"api_key": user_profile.api_key})
diff --git a/zerver/views/integrations.py b/zerver/views/integrations.py
index 6361c69294..27ed1a98f9 100644
--- a/zerver/views/integrations.py
+++ b/zerver/views/integrations.py
@@ -9,15 +9,31 @@ import ujson
from zerver.lib import bugdown
from zerver.lib.integrations import INTEGRATIONS
+from zerver.lib.utils import get_subdomain
from zproject.jinja2 import render_to_response
def add_api_uri_context(context, request):
# type: (Dict[str, Any], HttpRequest) -> None
- external_api_path_subdomain = settings.EXTERNAL_API_PATH
- external_api_uri_subdomain = settings.EXTERNAL_API_URI
+ if settings.REALMS_HAVE_SUBDOMAINS:
+ subdomain = get_subdomain(request)
+ if subdomain:
+ display_subdomain = subdomain
+ html_settings_links = True
+ else:
+ display_subdomain = 'yourZulipDomain'
+ html_settings_links = False
+ external_api_path_subdomain = '%s.%s' % (display_subdomain,
+ settings.EXTERNAL_API_PATH)
+ else:
+ external_api_path_subdomain = settings.EXTERNAL_API_PATH
+ html_settings_links = True
+
+ external_api_uri_subdomain = '%s%s' % (settings.EXTERNAL_URI_SCHEME,
+ external_api_path_subdomain)
context['external_api_path_subdomain'] = external_api_path_subdomain
context['external_api_uri_subdomain'] = external_api_uri_subdomain
+ context["html_settings_links"] = html_settings_links
class ApiURLView(TemplateView):
def get_context_data(self, **kwargs):
@@ -26,7 +42,6 @@ class ApiURLView(TemplateView):
add_api_uri_context(context, self.request)
return context
-
class APIView(ApiURLView):
template_name = 'zerver/api.html'
@@ -40,8 +55,12 @@ class IntegrationView(ApiURLView):
alphabetical_sorted_integration = OrderedDict(sorted(INTEGRATIONS.items()))
context['integrations_dict'] = alphabetical_sorted_integration
- settings_html = 'Zulip settings page'
- subscriptions_html = 'subscriptions page'
+ if context["html_settings_links"]:
+ settings_html = 'Zulip settings page'
+ subscriptions_html = 'subscriptions page'
+ else:
+ settings_html = 'Zulip settings page'
+ subscriptions_html = 'subscriptions page'
context['settings_html'] = settings_html
context['subscriptions_html'] = subscriptions_html
diff --git a/zilencer/management/commands/populate_db.py b/zilencer/management/commands/populate_db.py
index 5c6c2c12be..80ae182b6c 100644
--- a/zilencer/management/commands/populate_db.py
+++ b/zilencer/management/commands/populate_db.py
@@ -123,7 +123,7 @@ class Command(BaseCommand):
clear_database()
# Create our two default realms
- zulip_realm = Realm.objects.create(domain="zulip.com", name="Zulip Dev")
+ zulip_realm = Realm.objects.create(domain="zulip.com", name="Zulip Dev", subdomain="zulip")
if options["test_suite"]:
Realm.objects.create(domain="mit.edu")
realms = {} # type: Dict[text_type, Realm]
diff --git a/zproject/backends.py b/zproject/backends.py
index 16c8ecac9f..1691bf1ea7 100644
--- a/zproject/backends.py
+++ b/zproject/backends.py
@@ -22,6 +22,7 @@ from social.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \
GithubTeamOAuth2
from social.exceptions import AuthFailed
from django.contrib.auth import authenticate
+from zerver.lib.utils import check_subdomain
def password_auth_enabled(realm):
# type: (Realm) -> bool
@@ -115,6 +116,11 @@ class SocialAuthMixin(ZulipAuthMixin):
return_data["inactive_realm"] = True
return None
+ if not check_subdomain(kwargs.get("realm_subdomain"),
+ user_profile.realm.subdomain):
+ return_data["invalid_subdomain"] = True
+ return None
+
return user_profile
def process_do_auth(self, user_profile, *args, **kwargs):
@@ -142,10 +148,14 @@ class ZulipDummyBackend(ZulipAuthMixin):
"""
Used when we want to log you in but we don't know which backend to use.
"""
- def authenticate(self, username=None, use_dummy_backend=False):
- # type: (Optional[str], bool) -> Optional[UserProfile]
+ def authenticate(self, username=None, realm_subdomain=None, use_dummy_backend=False):
+ # type: (Optional[text_type], Optional[text_type], bool) -> Optional[UserProfile]
if use_dummy_backend:
- return common_get_active_user_by_email(username)
+ user_profile = common_get_active_user_by_email(username)
+ if user_profile is None:
+ return None
+ if check_subdomain(realm_subdomain, user_profile.realm.subdomain):
+ return user_profile
return None
class EmailAuthBackend(ZulipAuthMixin):
@@ -155,9 +165,8 @@ class EmailAuthBackend(ZulipAuthMixin):
Allows a user to sign in using an email/password pair rather than
a username/password pair.
"""
-
- def authenticate(self, username=None, password=None, return_data=None):
- # type: (Optional[text_type], Optional[str], Optional[Dict[str, Any]]) -> Optional[UserProfile]
+ def authenticate(self, username=None, password=None, realm_subdomain=None, return_data=None):
+ # type: (Optional[text_type], Optional[str], Optional[text_type], Optional[Dict[str, Any]]) -> Optional[UserProfile]
""" Authenticate a user based on email address as the user name. """
if username is None or password is None:
# Return immediately. Otherwise we will look for a SQL row with
@@ -173,7 +182,11 @@ class EmailAuthBackend(ZulipAuthMixin):
return_data['password_auth_disabled'] = True
return None
if user_profile.check_password(password):
+ if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
+ return_data["invalid_subdomain"] = True
+ return None
return user_profile
+ return None
class GoogleMobileOauth2Backend(ZulipAuthMixin):
"""
@@ -186,8 +199,8 @@ class GoogleMobileOauth2Backend(ZulipAuthMixin):
https://developers.google.com/accounts/docs/CrossClientAuth#offlineAccess
"""
- def authenticate(self, google_oauth2_token=None, return_data=dict()):
- # type: (Optional[str], Dict[str, Any]) -> Optional[UserProfile]
+ def authenticate(self, google_oauth2_token=None, realm_subdomain=None, return_data={}):
+ # type: (Optional[str], Optional[text_type], Dict[str, Any]) -> Optional[UserProfile]
try:
token_payload = googleapiclient.verify_id_token(google_oauth2_token, settings.GOOGLE_CLIENT_ID)
except AppIdentityError:
@@ -204,20 +217,25 @@ class GoogleMobileOauth2Backend(ZulipAuthMixin):
if user_profile.realm.deactivated:
return_data["inactive_realm"] = True
return None
+ if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
+ return_data["invalid_subdomain"] = True
+ return None
return user_profile
else:
return_data["valid_attestation"] = False
class ZulipRemoteUserBackend(RemoteUserBackend):
create_unknown_user = False
-
- def authenticate(self, remote_user):
- # type: (str) -> Optional[UserProfile]
+ def authenticate(self, remote_user, realm_subdomain=None):
+ # type: (str, Optional[text_type]) -> Optional[UserProfile]
if not remote_user:
return None
email = remote_user_to_email(remote_user)
- return common_get_active_user_by_email(email)
+ user = common_get_active_user_by_email(email)
+ if user is not None and check_subdomain(realm_subdomain, user.realm.subdomain):
+ return user
+ return None
class ZulipLDAPException(Exception):
pass
@@ -257,11 +275,16 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
return username
class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
- def authenticate(self, username, password, return_data=None):
- # type: (text_type, str, Optional[Dict[str, Any]]) -> Optional[str]
+ def authenticate(self, username, password, realm_subdomain=None, return_data=None):
+ # type: (text_type, str, Optional[text_type], Optional[Dict[str, Any]]) -> Optional[str]
try:
username = self.django_to_ldap_username(username)
- return ZulipLDAPAuthBackendBase.authenticate(self, username, password)
+ user_profile = ZulipLDAPAuthBackendBase.authenticate(self, username, password)
+ if user_profile is None:
+ return None
+ if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
+ return None
+ return user_profile
except Realm.DoesNotExist:
return None
except ZulipLDAPException:
@@ -292,16 +315,15 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
# Just like ZulipLDAPAuthBackend, but doesn't let you log in.
class ZulipLDAPUserPopulator(ZulipLDAPAuthBackendBase):
- def authenticate(self, username, password):
- # type: (text_type, str) -> None
+ def authenticate(self, username, password, realm_subdomain=None):
+ # type: (text_type, str, Optional[text_type]) -> None
return None
class DevAuthBackend(ZulipAuthMixin):
# Allow logging in as any user without a password.
# This is used for convenience when developing Zulip.
-
- def authenticate(self, username, return_data=None):
- # type: (text_type, Optional[Dict[str, Any]]) -> UserProfile
+ def authenticate(self, username, realm_subdomain=None, return_data=None):
+ # type: (text_type, Optional[text_type], Optional[Dict[str, Any]]) -> UserProfile
return common_get_active_user_by_email(username, return_data=return_data)
class GitHubAuthBackend(SocialAuthMixin, GithubOAuth2):
diff --git a/zproject/dev_settings.py b/zproject/dev_settings.py
index 91f02dccbb..ef047b3dcf 100644
--- a/zproject/dev_settings.py
+++ b/zproject/dev_settings.py
@@ -4,8 +4,8 @@
from .prod_settings_template import *
LOCAL_UPLOADS_DIR = 'var/uploads'
-EXTERNAL_HOST = 'localhost:9991'
-ALLOWED_HOSTS = ['localhost']
+EXTERNAL_HOST = 'zulipdev.com:9991'
+ALLOWED_HOSTS = ['*']
AUTHENTICATION_BACKENDS = ('zproject.backends.DevAuthBackend',)
# Add some of the below if you're testing other backends
# AUTHENTICATION_BACKENDS = ('zproject.backends.EmailAuthBackend',
@@ -20,6 +20,9 @@ EXTRA_INSTALLED_APPS = ["zilencer", "analytics"]
# Disable Camo in development
CAMO_URI = ''
OPEN_REALM_CREATION = True
+# Default to subdomains disabled in development until we can update
+# the development documentation to make sense with subdomains.
+REALMS_HAVE_SUBDOMAINS = False
TERMS_OF_SERVICE = 'zproject/terms.md.template'
SAVE_FRONTEND_STACKTRACES = True
diff --git a/zproject/settings.py b/zproject/settings.py
index cdde7af269..35bfec1ba4 100644
--- a/zproject/settings.py
+++ b/zproject/settings.py
@@ -158,6 +158,7 @@ DEFAULT_SETTINGS = {'TWITTER_CONSUMER_KEY': '',
'VERBOSE_SUPPORT_OFFERS': False,
'STATSD_HOST': '',
'OPEN_REALM_CREATION': False,
+ 'REALMS_HAVE_SUBDOMAINS': False,
'REMOTE_POSTGRES_HOST': '',
'REMOTE_POSTGRES_SSLMODE': '',
# Default GOOGLE_CLIENT_ID to the value needed for Android auth to work
diff --git a/zproject/test_settings.py b/zproject/test_settings.py
index a46a80e321..340bb6c0c9 100644
--- a/zproject/test_settings.py
+++ b/zproject/test_settings.py
@@ -82,6 +82,8 @@ LOCAL_UPLOADS_DIR = 'var/test_uploads'
S3_KEY = 'test-key'
S3_SECRET_KEY = 'test-secret-key'
S3_AUTH_UPLOADS_BUCKET = 'test-authed-bucket'
+EXTERNAL_HOST = os.getenv('EXTERNAL_HOST', "testserver")
+REALMS_HAVE_SUBDOMAINS = bool(os.getenv('REALMS_HAVE_SUBDOMAINS', False))
# Test Custom TOS template rendering
TERMS_OF_SERVICE = 'corporate/terms.md'