Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/pretalx/agenda/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ def get_schedule_urls(regex_prefix, name_prefix=""):
(".xcal", schedule.ExporterView.as_view(), "export.schedule.xcal"),
(".json", schedule.ExporterView.as_view(), "export.schedule.json"),
(".ics", schedule.ExporterView.as_view(), "export.schedule.ics"),
("/export/google-calendar", schedule.GoogleCalendarRedirectView.as_view(), "export.google-calendar"),
("/export/my-google-calendar", schedule.GoogleCalendarRedirectView.as_view(), "export.my-google-calendar"),
("/export/google-calendar", schedule.CalendarRedirectView.as_view(), "export.google-calendar"),
("/export/my-google-calendar", schedule.CalendarRedirectView.as_view(), "export.my-google-calendar"),
("/export/webcal", schedule.CalendarRedirectView.as_view(), "export.webcal"),
("/export/my-webcal", schedule.CalendarRedirectView.as_view(), "export.my-webcal"),
("/export/<name>", schedule.ExporterView.as_view(), "export"),
("/widgets/schedule.json", widget.widget_data, "widget.data"),
# Legacy widget data URL, but expected in old widget code.
Expand Down
88 changes: 54 additions & 34 deletions src/pretalx/agenda/views/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@


class ScheduleMixin:
MY_STARRED_ICS_TOKEN_SESSION_KEY = 'my_starred_ics_token'

@cached_property
def version(self):
if version := self.kwargs.get("version"):
Expand Down Expand Up @@ -69,19 +71,27 @@ def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)

@staticmethod
def generate_ics_token(user_id):
"""Generate a signed token with user ID and 15-day expiry"""
def generate_ics_token(request, user_id):
"""Generate a signed token with user ID and 15-day expiry, invalidating previous tokens"""
# Clear any existing token from the session
key = ScheduleMixin.MY_STARRED_ICS_TOKEN_SESSION_KEY
if key in request.session:
del request.session[key]

# Generate new token
expiry = timezone.now() + timedelta(days=15)
value = {"user_id": user_id, "exp": int(expiry.timestamp())}
return signing.dumps(value, salt="my-starred-ics")
token = signing.dumps(value, salt="my-starred-ics")

# Store new token in session
request.session[key] = token
return token

@staticmethod
def parse_ics_token(token):
"""Parse and validate the token, return user_id if valid"""
try:
value = signing.loads(token, salt="my-starred-ics", max_age=15*24*60*60)
if value["exp"] < int(timezone.now().timestamp()):
raise ValueError("Token expired")
return value["user_id"]
except (signing.BadSignature, signing.SignatureExpired, KeyError, ValueError) as e:
logger.warning('Failed to parse ICS token: %s', e)
Expand All @@ -97,7 +107,7 @@ def check_token_expiry(token):
- True if token is valid and not expiring soon (>= 4 days)
"""
try:
value = signing.loads(token, salt="my-starred-ics")
value = signing.loads(token, salt="my-starred-ics", max_age=15*24*60*60)
expiry_date = timezone.datetime.fromtimestamp(value["exp"], tz=timezone.utc)
time_until_expiry = expiry_date - timezone.now()
return time_until_expiry >= timedelta(days=4)
Expand Down Expand Up @@ -126,15 +136,10 @@ def get_context_data(self, **kwargs):

def get_exporter(self, public=True):
url = resolve(self.request.path_info)

# Handle both export and export-tokenized URLs
if url.url_name in ["export", "export-tokenized"]:
exporter = url.kwargs.get("name") or unquote(
self.request.GET.get("exporter")
)
elif url.url_name in ["export.google-calendar", "export.my-google-calendar"]:
# Handle our explicit Google Calendar URL patterns
exporter = url.url_name.replace("export.", "")
else:
exporter = url.url_name

Expand Down Expand Up @@ -360,36 +365,37 @@ class ChangelogView(EventPermissionRequired, TemplateView):
permission_required = "agenda.view_schedule"


class GoogleCalendarRedirectView(EventPermissionRequired, ScheduleMixin, TemplateView):
# Define constant for session key
MY_STARRED_ICS_TOKEN_SESSION_KEY = 'my_starred_ics_token'
class CalendarRedirectView(EventPermissionRequired, ScheduleMixin, TemplateView):
"""Handles redirects for both Google Calendar and other calendar applications"""
permission_required = "agenda.view_schedule"

def get(self, request, *args, **kwargs):
# Use resolver_match.url_name for robust route detection
# Get URL name from resolver
url_name = request.resolver_match.url_name if request.resolver_match else None
if url_name == 'export.my-google-calendar':
# Generate tokenized URL for my starred sessions
# Determine calendar type and starred status from URL pattern
is_google = "google" in url_name
is_my = "my" in url_name

if is_my:
# For starred sessions
if not request.user.is_authenticated:
return HttpResponseRedirect(self.request.event.urls.login)
login_url = f"{self.request.event.urls.login}?{urlencode({'next': request.get_full_path()})}"
return HttpResponseRedirect(login_url)

# Use constant instead of hardcoded string
# Check for existing valid token
existing_token = request.session.get(self.MY_STARRED_ICS_TOKEN_SESSION_KEY)
generate_new_token = True

# If we have an existing token, check if it's still valid and not expiring soon
if existing_token:
token_status = self.check_token_expiry(existing_token)
if token_status is True:
if token_status is True: # Token is valid and has at least 4 days left
token = existing_token
generate_new_token = False

# Generate a new token if needed
# Generate new token if needed (this will invalidate any existing token)
if generate_new_token:
token = self.generate_ics_token(request.user.id)
# Use constant here too
request.session[self.MY_STARRED_ICS_TOKEN_SESSION_KEY] = token
token = self.generate_ics_token(request, request.user.id)

# Build tokenized URL for starred sessions
ics_url = request.build_absolute_uri(
reverse('agenda:export-tokenized', kwargs={
'event': self.request.event.slug,
Expand All @@ -398,19 +404,33 @@ def get(self, request, *args, **kwargs):
})
)
else:
# Regular public calendar
# Build public calendar URL
ics_url = request.build_absolute_uri(
reverse('agenda:export', kwargs={
'event': self.request.event.slug,
'name': 'schedule.ics'
})
)

# Change scheme to webcal
parsed = urlparse(ics_url)
ics_url = urlunparse(('webcal',) + parsed[1:])

# Create Google Calendar URL
google_url = f"https://calendar.google.com/calendar/render?{urlencode({'cid': ics_url})}"
# Handle redirect based on calendar type
if is_google:
# Google Calendar requires special URL format
google_url = f"https://calendar.google.com/calendar/render?{urlencode({'cid': ics_url})}"
# HTML-based redirection works more reliably across calendar clients like Outlook and Apple Calendar which often mishandle HTTP 302s.
response = HttpResponse(
f'<html><head><meta http-equiv="refresh" content="0;url={google_url}"></head>'
f'<body><p style="text-align: center; padding:2vw; font-family: Roboto,Helvetica Neue,HelveticaNeue,Helvetica,Arial,sans-serif;">Redirecting to Google Calendar: {google_url}</p><script>window.location.href="{google_url}";</script></body></html>',
content_type='text/html'
)
return response

return HttpResponseRedirect(google_url)
# Other calendars use webcal protocol
parsed = urlparse(ics_url)
webcal_url = urlunparse(('webcal',) + parsed[1:])
# HTML-based redirection works more reliably across calendar clients like Outlook and Apple Calendar which often mishandle HTTP 302s.
response = HttpResponse(
f'<html><head><meta http-equiv="refresh" content="0;url={webcal_url}"></head>'
f'<body><p style="text-align: center; padding:2vw; font-family: Roboto,Helvetica Neue,HelveticaNeue,Helvetica,Arial,sans-serif;">Redirecting to: {webcal_url}</p><script>window.location.href="{webcal_url}";</script></body></html>',
content_type='text/html'
)
return response
21 changes: 17 additions & 4 deletions src/pretalx/schedule/exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,20 +429,33 @@ def render(self, request, **kwargs):
return f"{self.event.slug}-favs.ics", "text/calendar", cal.serialize()


class BaseGoogleCalendarExporter(BaseExporter):
class BaseCalendarExporter(BaseExporter):
public = True
show_qrcode = False
icon = "fa-google"
icon = "fa-calendar"

@property
def show_public(self):
return self.ical_exporter_cls(self.event).show_public

class GoogleCalendarExporter(BaseGoogleCalendarExporter):
class GoogleCalendarExporter(BaseCalendarExporter):
identifier = "google-calendar"
verbose_name = "Add to Google Calendar"
icon = "fa-google"
ical_exporter_cls = ICalExporter

class MyGoogleCalendarExporter(BaseGoogleCalendarExporter):
class MyGoogleCalendarExporter(BaseCalendarExporter):
identifier = "my-google-calendar"
icon = "fa-google"
verbose_name = "Add My ⭐ Sessions to Google Calendar"
ical_exporter_cls = MyICalExporter

class WebcalExporter(BaseCalendarExporter):
identifier = "webcal"
verbose_name = "Add to Other Calendar"
ical_exporter_cls = ICalExporter

class MyWebcalExporter(BaseCalendarExporter):
identifier = "my-webcal"
verbose_name = "Add My ⭐ Sessions to Other Calendar"
ical_exporter_cls = MyICalExporter
19 changes: 19 additions & 0 deletions src/pretalx/schedule/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,31 @@ def register_my_json_exporter(sender, **kwargs):

return MyFrabJsonExporter


@receiver(register_data_exporters, dispatch_uid="exporter_builtin_google_calendar")
def register_google_calendar_exporter(sender, **kwargs):
from .exporters import GoogleCalendarExporter

return GoogleCalendarExporter


@receiver(register_data_exporters, dispatch_uid="exporter_builtin_webcal")
def register_webcal_exporter(sender, **kwargs):
from .exporters import WebcalExporter

return WebcalExporter


@receiver(register_my_data_exporters, dispatch_uid="exporter_builtin_my_google_calendar")
def register_my_google_calendar_exporter(sender, **kwargs):
from .exporters import MyGoogleCalendarExporter

return MyGoogleCalendarExporter


@receiver(register_my_data_exporters, dispatch_uid="exporter_builtin_my_webcal")
def register_my_webcal_exporter(sender, **kwargs):
from .exporters import MyWebcalExporter

return MyWebcalExporter

Loading