diff --git a/doc/admin/config.rst b/doc/admin/config.rst index 7825ee58f..ed4b4748d 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -27,8 +27,6 @@ Example:: instance_name=pretix.de url=http://localhost currency=EUR - cookiedomain=.pretix.de - securecookie=on datadir=/data plugins_default=pretix.plugins.sendmail,pretix.plugins.statistics @@ -41,13 +39,6 @@ Example:: ``currency`` The default currency as a three-letter code. Defaults to ``EUR``. -``cookiedomain`` - The domain to be used for session cookies, csrf protection cookies and locale cookies. - Empty by default. - -``securecookie`` - Set the ``secure`` and ``httponly`` flags on session cookies. Off by default. - ``datadir`` The local path to a data directory that will be used for storing user uploads and similar data. Defaults to thea value of the environment variable ``DATA_DIR`` or ``data``. diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 8502ce0ac..c3fa9b77b 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -9,12 +9,11 @@ from pretix.base.models import ( CartPosition, Event, EventLock, Order, OrderPosition, Quota, ) from pretix.base.payment import BasePaymentProvider -from pretix.base.services.cart import CartError from pretix.base.services.mail import mail from pretix.base.signals import ( order_paid, order_placed, register_payment_providers, ) -from pretix.helpers.urls import build_absolute_uri +from pretix.multidomain.urlreverse import build_absolute_uri error_messages = { 'unavailable': _('Some of the products you selected were no longer available. ' @@ -68,9 +67,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date { 'order': order, 'event': order.event, - 'url': build_absolute_uri('presale:event.order', kwargs={ - 'event': order.event.slug, - 'organizer': order.event.organizer.slug, + 'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ 'order': order.code, 'secret': order.secret }), @@ -189,9 +186,7 @@ def _perform_order(event: Event, payment_provider: BasePaymentProvider, position { 'order': order, 'event': event, - 'url': build_absolute_uri('presale:event.order', kwargs={ - 'event': event.slug, - 'organizer': event.organizer.slug, + 'url': build_absolute_uri(event, 'presale:event.order', kwargs={ 'order': order.code, 'secret': order.secret }), diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index 1e8042645..35030d2e2 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -1,10 +1,10 @@ -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME from django.core.urlresolvers import get_script_prefix, resolve from django.http import Http404 -from django.shortcuts import resolve_url +from django.shortcuts import redirect, resolve_url from django.utils.encoding import force_str from django.utils.translation import ugettext as _ @@ -28,7 +28,13 @@ class PermissionMiddleware: def process_request(self, request): url = resolve(request.path_info) url_name = url.url_name - if not request.path.startswith(get_script_prefix() + 'control') or url_name in self.EXCEPTIONS: + if not request.path.startswith(get_script_prefix() + 'control'): + # This middleware should only touch the /control subpath + return + if hasattr(request, 'domain'): + # If the user is on a organizer's subdomain, he sould be redirected to pretix + return redirect(urljoin(settings.SITE_URL, request.get_full_path())) + if url_name in self.EXCEPTIONS: return if not request.user.is_authenticated(): # Taken from django/contrib/auth/decorators.py diff --git a/src/pretix/multidomain/__init__.py b/src/pretix/multidomain/__init__.py new file mode 100644 index 000000000..b051cac35 --- /dev/null +++ b/src/pretix/multidomain/__init__.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class PretixMultidomainConfig(AppConfig): + name = 'pretix.multidomain' + label = 'pretixmultidomain' + +default_app_config = 'pretix.multidomain.PretixMultidomainConfig' diff --git a/src/pretix/multidomain/middlewares.py b/src/pretix/multidomain/middlewares.py new file mode 100644 index 000000000..362fa8922 --- /dev/null +++ b/src/pretix/multidomain/middlewares.py @@ -0,0 +1,112 @@ +import time +from urllib.parse import urlparse + +from django.conf import settings +from django.contrib.sessions.middleware import \ + SessionMiddleware as BaseSessionMiddleware +from django.core.exceptions import DisallowedHost +from django.http.request import split_domain_port +from django.middleware.csrf import CsrfViewMiddleware as BaseCsrfMiddleware +from django.utils.cache import patch_vary_headers +from django.utils.http import cookie_date + +from pretix.multidomain.models import KnownDomain + + +class MultiDomainMiddleware: + def process_request(self, request): + # We try three options, in order of decreasing preference. + if settings.USE_X_FORWARDED_HOST and ('HTTP_X_FORWARDED_HOST' in request.META): + host = request.META['HTTP_X_FORWARDED_HOST'] + elif 'HTTP_HOST' in request.META: + host = request.META['HTTP_HOST'] + else: + # Reconstruct the host using the algorithm from PEP 333. + host = request.META['SERVER_NAME'] + server_port = str(request.META['SERVER_PORT']) + if server_port != ('443' if request.is_secure() else '80'): + host = '%s:%s' % (host, server_port) + + domain, port = split_domain_port(host) + default_domain, default_port = split_domain_port(urlparse(settings.SITE_URL).netloc) + if domain: + request.host = domain + request.port = int(port) if port else None + try: + kd = KnownDomain.objects.get(domainname=domain) # noqa + request.domain = kd + except: + if settings.DEBUG or domain in ('testserver', 'localhost') or domain == default_domain: + return # TODO: Select main page + raise DisallowedHost("Unknown host: %r" % host) + else: + request.organizer = kd.organizer + else: + raise DisallowedHost("Invalid HTTP_HOST header: %r." % host) + + +class SessionMiddleware(BaseSessionMiddleware): + def process_response(self, request, response): + try: + accessed = request.session.accessed + modified = request.session.modified + empty = request.session.is_empty() + except AttributeError: + pass + else: + # First check if we need to delete this cookie. + # The session should be deleted only if the session is entirely empty + if settings.SESSION_COOKIE_NAME in request.COOKIES and empty: + response.delete_cookie(settings.SESSION_COOKIE_NAME) + else: + if accessed: + patch_vary_headers(response, ('Cookie',)) + if modified or settings.SESSION_SAVE_EVERY_REQUEST: + if request.session.get_expire_at_browser_close(): + max_age = None + expires = None + else: + max_age = request.session.get_expiry_age() + expires_time = time.time() + max_age + expires = cookie_date(expires_time) + # Save the session data and refresh the client cookie. + # Skip session save for 500 responses, refs #3881. + if response.status_code != 500: + request.session.save() + response.set_cookie(settings.SESSION_COOKIE_NAME, + request.session.session_key, max_age=max_age, + expires=expires, domain=request.host, + path=settings.SESSION_COOKIE_PATH, + secure=request.scheme == 'https', + httponly=settings.SESSION_COOKIE_HTTPONLY or None) + return response + + +class CsrfViewMiddleware(BaseCsrfMiddleware): + def process_response(self, request, response): + if getattr(response, 'csrf_processing_done', False): + return response + + # If CSRF_COOKIE is unset, then CsrfViewMiddleware.process_view was + # never called, probably because a request middleware returned a response + # (for example, contrib.auth redirecting to a login page). + if request.META.get("CSRF_COOKIE") is None: + return response + + if not request.META.get("CSRF_COOKIE_USED", False): + return response + + # Set the CSRF cookie even if it's already set, so we renew + # the expiry timer. + response.set_cookie(settings.CSRF_COOKIE_NAME, + request.META["CSRF_COOKIE"], + max_age=settings.CSRF_COOKIE_AGE, + domain=request.host, + path=settings.CSRF_COOKIE_PATH, + secure=request.scheme == 'https', + httponly=settings.CSRF_COOKIE_HTTPONLY + ) + # Content varies with the CSRF cookie, so set the Vary header. + patch_vary_headers(response, ('Cookie',)) + response.csrf_processing_done = True + return response diff --git a/src/pretix/multidomain/migrations/0001_initial.py b/src/pretix/multidomain/migrations/0001_initial.py new file mode 100644 index 000000000..08f438626 --- /dev/null +++ b/src/pretix/multidomain/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import versions.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='KnownDomain', + fields=[ + ('domainname', models.CharField(serialize=False, max_length=255, primary_key=True)), + ('organizer', versions.models.VersionedForeignKey(blank=True, to='pretixbase.Organizer', null=True)), + ], + ), + ] diff --git a/src/pretix/multidomain/migrations/0002_auto_20151018_1007.py b/src/pretix/multidomain/migrations/0002_auto_20151018_1007.py new file mode 100644 index 000000000..b3545a167 --- /dev/null +++ b/src/pretix/multidomain/migrations/0002_auto_20151018_1007.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import versions.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixmultidomain', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='knowndomain', + options={'verbose_name': 'Known domain', 'verbose_name_plural': 'Known domains'}, + ), + migrations.AlterField( + model_name='knowndomain', + name='organizer', + field=versions.models.VersionedForeignKey(to='pretixbase.Organizer', null=True, related_name='domains', blank=True), + ), + ] diff --git a/src/pretix/multidomain/migrations/__init__.py b/src/pretix/multidomain/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pretix/multidomain/models.py b/src/pretix/multidomain/models.py new file mode 100644 index 000000000..ce7dfdd7f --- /dev/null +++ b/src/pretix/multidomain/models.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from versions.models import VersionedForeignKey + +from pretix.base.models import Organizer + + +class KnownDomain(models.Model): + domainname = models.CharField(max_length=255, primary_key=True) + organizer = VersionedForeignKey(Organizer, blank=True, null=True, related_name='domains') + + class Meta: + verbose_name = _("Known domain") + verbose_name_plural = _("Known domains") + + def __str__(self): + return self.domainname + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.organizer: + self.organizer.get_cache().clear() + + def delete(self, *args, **kwargs): + if self.organizer: + self.organizer.get_cache().clear() + super().delete(*args, **kwargs) diff --git a/src/pretix/multidomain/templatetags/__init__.py b/src/pretix/multidomain/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pretix/multidomain/templatetags/eventurl.py b/src/pretix/multidomain/templatetags/eventurl.py new file mode 100644 index 000000000..2dc5fbd0f --- /dev/null +++ b/src/pretix/multidomain/templatetags/eventurl.py @@ -0,0 +1,71 @@ +from django import template +from django.core.urlresolvers import NoReverseMatch +from django.template import TemplateSyntaxError +from django.template.base import kwarg_re +from django.template.defaulttags import URLNode +from django.utils.encoding import smart_text +from django.utils.html import conditional_escape + +register = template.Library() + + +class EventURLNode(URLNode): + def __init__(self, event, view_name, kwargs, asvar): + self.event = event + super().__init__(view_name, [], kwargs, asvar) + + def render(self, context): + from pretix.multidomain.urlreverse import eventreverse + kwargs = { + smart_text(k, 'ascii'): v.resolve(context) + for k, v in self.kwargs.items() + } + view_name = self.view_name.resolve(context) + event = self.event.resolve(context) + url = '' + try: + url = eventreverse(event, view_name, kwargs=kwargs) + except NoReverseMatch: + if self.asvar is None: + raise + + if self.asvar: + context[self.asvar] = url + return '' + else: + if context.autoescape: + url = conditional_escape(url) + return url + + +@register.tag +def eventurl(parser, token): + """ + Similar to {% url %} in the same way that eventreverse() is similar to reverse(). + + Takes an event object, an url name and optional keyword arguments + """ + bits = token.split_contents() + if len(bits) < 3: + raise TemplateSyntaxError("'%s' takes at least one argument, an event and the name of a url()." % bits[0]) + viewname = parser.compile_filter(bits[2]) + event = parser.compile_filter(bits[1]) + kwargs = {} + asvar = None + bits = bits[3:] + if len(bits) >= 3 and bits[-2] == 'as': + asvar = bits[-1] + bits = bits[:-2] + + if len(bits): + for bit in bits: + match = kwarg_re.match(bit) + if not match: + raise TemplateSyntaxError("Malformed arguments to eventurl tag") + name, value = match.groups() + if name: + kwargs[name] = parser.compile_filter(value) + else: + raise TemplateSyntaxError('Event urls only have keyword arguments.') + + return EventURLNode(event, viewname, kwargs, asvar) diff --git a/src/pretix/multidomain/urlreverse.py b/src/pretix/multidomain/urlreverse.py new file mode 100644 index 000000000..208326a2b --- /dev/null +++ b/src/pretix/multidomain/urlreverse.py @@ -0,0 +1,44 @@ +from urllib.parse import urljoin, urlsplit + +from django.conf import settings +from django.core.urlresolvers import reverse + + +def get_domain(event): + c = event.organizer.get_cache() + domain = c.get('domain') + if domain is None: + domains = event.organizer.domains.all() + domain = domains[0].domainname if domains else None + c.set('domain', domain or 'none') + elif domain == 'none': + return None + return domain + + +def eventreverse(event, name, kwargs=None): + """ + Works similar to django.core.urlresolvers.reverse but takes into account that some + organizers might have their own (sub)domain instead of a subpath. + """ + kwargs = kwargs or {} + kwargs['event'] = event.slug + domain = get_domain(event) + if domain: + if 'organizer' in kwargs: + del kwargs['organizer'] + path = reverse(name, kwargs=kwargs) + siteurlsplit = urlsplit(settings.SITE_URL) + if siteurlsplit.port and siteurlsplit.port not in (80, 443): + domain = '%s:%d' % (domain, siteurlsplit.port) + return urljoin('%s://%s' % (siteurlsplit.scheme, domain), path) + + kwargs['organizer'] = event.organizer.slug + return reverse(name, kwargs=kwargs) + + +def build_absolute_uri(event, urlname, kwargs=None): + reversedurl = eventreverse(event, urlname, kwargs) + if '://' in reversedurl: + return reversedurl + return urljoin(settings.SITE_URL, reversedurl) diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 711ef37b0..cc6d3a1fd 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -11,7 +11,7 @@ from django.utils.translation import ugettext as __, ugettext_lazy as _ from pretix.base.models import Quota from pretix.base.payment import BasePaymentProvider from pretix.base.services.orders import mark_order_paid -from pretix.helpers.urls import build_absolute_uri +from pretix.multidomain.urlreverse import build_absolute_uri logger = logging.getLogger('pretix.plugins.paypal') @@ -87,8 +87,8 @@ class Paypal(BasePaymentProvider): "payment_method": "paypal", }, "redirect_urls": { - "return_url": build_absolute_uri('plugins:paypal:return'), - "cancel_url": build_absolute_uri('plugins:paypal:abort'), + "return_url": build_absolute_uri(request.event, 'plugins:paypal:return'), + "cancel_url": build_absolute_uri(request.event, 'plugins:paypal:abort'), }, "transactions": [ { diff --git a/src/pretix/plugins/paypal/urls.py b/src/pretix/plugins/paypal/urls.py index 5ede8ffac..43e0b31de 100644 --- a/src/pretix/plugins/paypal/urls.py +++ b/src/pretix/plugins/paypal/urls.py @@ -3,7 +3,7 @@ from django.conf.urls import include, url from .views import abort, retry, success urlpatterns = [ - url(r'^paypal/', include([ + url(r'^(?:(?P[^/]+)/)?(?P[^/]+)/paypal/', include([ url(r'^abort/$', abort, name='abort'), url(r'^return/$', success, name='return'), url(r'^retry/(?P[^/]+)/', retry, name='retry') diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py index b79731ad4..4ef3c32c5 100644 --- a/src/pretix/plugins/paypal/views.py +++ b/src/pretix/plugins/paypal/views.py @@ -8,6 +8,7 @@ from django.utils.translation import ugettext as __, ugettext_lazy as _ from pretix.base.models import Event, Order from pretix.helpers.urls import build_absolute_uri +from pretix.multidomain.urlreverse import eventreverse from pretix.plugins.paypal.payment import Paypal logger = logging.getLogger('pretix.plugins.paypal') @@ -23,10 +24,7 @@ def success(request): request.session['payment_paypal_payer'] = payer try: event = Event.objects.current.get(identity=request.session['payment_paypal_event']) - return redirect('presale:event.checkout', - event=event.slug, - organizer=event.organizer.slug, - step='confirm') + return redirect(eventreverse(event, 'presale:event.checkout', kwargs={'step': 'confirm'})) except Event.DoesNotExist: pass # TODO: Handle this else: @@ -38,10 +36,7 @@ def abort(request): messages.error(request, _('It looks like you cancelled the PayPal payment')) try: event = Event.objects.current.get(identity=request.session['payment_paypal_event']) - return redirect('presale:event.checkout', - event=event.slug, - organizer=event.organizer.slug, - step='payment') + return redirect(eventreverse(event, 'presale:event.checkout', kwargs={'step': 'payment'})) except Event.DoesNotExist: pass # TODO: Handle this @@ -103,8 +98,7 @@ def retry(request, order): if resp: return redirect(resp) - return redirect('presale:event.order', - event=order.event.slug, - organizer=order.event.organizer.slug, - order=order.code, - secret=order.secret) + '?paid=yes' + return redirect(eventreverse(order.event, 'presale:event.order', kwargs={ + 'order': order.code, + 'secret': order.secret + }) + '?paid=yes') diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 79ea409e3..c375f131e 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1,7 +1,6 @@ from django.conf import settings from django.contrib import messages from django.core.exceptions import ValidationError -from django.core.urlresolvers import reverse from django.core.validators import EmailValidator from django.db.models import Q, Sum from django.http import HttpResponseNotAllowed @@ -14,6 +13,7 @@ from django.views.generic.base import TemplateResponseMixin from pretix.base.models import CartPosition, Order from pretix.base.services.orders import OrderError, perform_order from pretix.base.signals import register_payment_providers +from pretix.multidomain.urlreverse import eventreverse from pretix.presale.forms.checkout import ContactForm from pretix.presale.signals import checkout_flow_steps from pretix.presale.views import CartMixin @@ -59,32 +59,19 @@ class BaseCheckoutFlowStep: return HttpResponseNotAllowed([]) def get_step_url(self): - return reverse( - 'presale:event.checkout', - kwargs={ - 'event': self.event.slug, - 'organizer': self.event.organizer.slug, - 'step': self.identifier - } - ) + return eventreverse(self.event, 'presale:event.checkout', kwargs={'step': self.identifier}) def get_prev_url(self, request): prev = self.get_prev_applicable(request) if not prev: - return reverse('presale:event.index', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug - }) + return eventreverse(self.event, 'presale:event.index') else: return prev.get_step_url() def get_next_url(self, request): n = self.get_next_applicable(request) if not n: - return reverse('presale:event.checkout.confirm', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug - }) + return eventreverse(self.event, 'presale:event.checkout.confirm') else: return n.get_step_url() @@ -342,9 +329,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): return self.get_step_url() def get_order_url(self, order): - return reverse('presale:event.order.pay.complete', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, + return eventreverse(self.request.event, 'presale:event.order.pay.complete', kwargs={ 'order': order.code, 'secret': order.secret }) diff --git a/src/pretix/presale/middleware.py b/src/pretix/presale/middleware.py index 02576b54d..69341a7b9 100644 --- a/src/pretix/presale/middleware.py +++ b/src/pretix/presale/middleware.py @@ -1,8 +1,12 @@ +from urllib.parse import urljoin + from django.core.urlresolvers import resolve from django.http import Http404 +from django.shortcuts import redirect from django.utils.translation import ugettext_lazy as _ from pretix.base.models import Event +from pretix.multidomain.urlreverse import get_domain class EventMiddleware: @@ -15,10 +19,36 @@ class EventMiddleware: if 'event.' in url_name and 'event' in url.kwargs: try: - request.event = Event.objects.current.filter( - slug=url.kwargs['event'], - organizer__slug=url.kwargs['organizer'], - ).select_related('organizer')[0] + if hasattr(request, 'organizer'): + # We are on an organizer's custom domain + if 'organizer' in url.kwargs and url.kwargs['organizer']: + if url.kwargs['organizer'] != request.organizer.slug: + raise Http404(_('The selected event was not found.')) + path = "/" + request.get_full_path().split("/", 2)[-1] + return redirect(path) + + request.event = Event.objects.current.filter( + slug=url.kwargs['event'], + organizer=request.organizer, + ).select_related('organizer')[0] + else: + # We are on our main domain + if 'organizer' not in url.kwargs: + raise Http404(_('The selected event was not found.')) + + request.event = Event.objects.current.filter( + slug=url.kwargs['event'], + organizer__slug=url.kwargs['organizer'] + ).select_related('organizer')[0] + + # If this organizer has a custom domain, send the user there + domain = get_domain(request.event) + if domain: + if request.port and request.port not in (80, 443): + domain = '%s:%d' % (domain, request.port) + path = request.get_full_path().split("/", 2)[-1] + return redirect(urljoin('%s://%s' % (request.scheme, domain), path)) + except IndexError: raise Http404(_('The selected event was not found.')) diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html index 85706dab8..b7b7520f2 100644 --- a/src/pretix/presale/templates/pretixpresale/event/base.html +++ b/src/pretix/presale/templates/pretixpresale/event/base.html @@ -1,6 +1,7 @@ {% load compress %} {% load staticfiles %} {% load i18n %} +{% load eventurl %} @@ -22,7 +23,7 @@