Skip to content

Commit 1ff4afa

Browse files
fix: Google calendar my starred sessions (#394)
* fixed google calander star sessions * sorcery suggested fixes * implemented suggested changes
1 parent a96caa9 commit 1ff4afa

File tree

2 files changed

+106
-20
lines changed

2 files changed

+106
-20
lines changed

src/pretalx/agenda/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ def get_schedule_urls(regex_prefix, name_prefix=""):
4545
widget.widget_script,
4646
name="widget.script",
4747
),
48+
path(
49+
"export/<str:name>/<str:token>/",
50+
schedule.ExporterView.as_view(),
51+
name="export-tokenized",
52+
),
4853
path("static/event.css", widget.event_css, name="event.css"),
4954
path(
5055
"schedule/changelog/",

src/pretalx/agenda/views/schedule.py

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
import textwrap
55
from contextlib import suppress
66
from urllib.parse import unquote, urlencode, urlparse, urlunparse
7+
from datetime import timedelta
78

89
from django.contrib import messages
10+
from django.core import signing
11+
from django.utils import timezone
912
from django.http import (
1013
Http404,
1114
HttpResponse,
@@ -65,6 +68,42 @@ def dispatch(self, request, *args, **kwargs):
6568
)
6669
return super().dispatch(request, *args, **kwargs)
6770

71+
@staticmethod
72+
def generate_ics_token(user_id):
73+
"""Generate a signed token with user ID and 15-day expiry"""
74+
expiry = timezone.now() + timedelta(days=15)
75+
value = {"user_id": user_id, "exp": int(expiry.timestamp())}
76+
return signing.dumps(value, salt="my-starred-ics")
77+
78+
@staticmethod
79+
def parse_ics_token(token):
80+
"""Parse and validate the token, return user_id if valid"""
81+
try:
82+
value = signing.loads(token, salt="my-starred-ics", max_age=15*24*60*60)
83+
if value["exp"] < int(timezone.now().timestamp()):
84+
raise ValueError("Token expired")
85+
return value["user_id"]
86+
except (signing.BadSignature, signing.SignatureExpired, KeyError, ValueError) as e:
87+
logger.warning('Failed to parse ICS token: %s', e)
88+
return None
89+
90+
@staticmethod
91+
def check_token_expiry(token):
92+
"""Check if a token exists and has more than 4 days until expiry
93+
94+
Returns:
95+
- None if token is invalid
96+
- False if token is valid but expiring soon (< 4 days)
97+
- True if token is valid and not expiring soon (>= 4 days)
98+
"""
99+
try:
100+
value = signing.loads(token, salt="my-starred-ics")
101+
expiry_date = timezone.datetime.fromtimestamp(value["exp"], tz=timezone.utc)
102+
time_until_expiry = expiry_date - timezone.now()
103+
return time_until_expiry >= timedelta(days=4)
104+
except Exception as e:
105+
logger.warning('Failed to check token expiry: %s', e)
106+
return None # Invalid token
68107

69108
class ExporterView(EventPermissionRequired, ScheduleMixin, TemplateView):
70109
permission_required = "agenda.view_schedule"
@@ -88,7 +127,8 @@ def get_context_data(self, **kwargs):
88127
def get_exporter(self, public=True):
89128
url = resolve(self.request.path_info)
90129

91-
if url.url_name == "export":
130+
# Handle both export and export-tokenized URLs
131+
if url.url_name in ["export", "export-tokenized"]:
92132
exporter = url.kwargs.get("name") or unquote(
93133
self.request.GET.get("exporter")
94134
)
@@ -118,19 +158,33 @@ def get(self, request, *args, **kwargs):
118158
elif "lang" in request.GET:
119159
activate(request.event.locale)
120160

121-
exporter.schedule = self.schedule
122-
if "-my" in exporter.identifier and self.request.user.id is None:
161+
# Handle tokenized access for Google Calendar integration
162+
token = kwargs.get('token')
163+
if token and "-my" in exporter.identifier:
164+
user_id = ScheduleMixin.parse_ics_token(token)
165+
if not user_id:
166+
raise Http404()
167+
168+
# Set up exporter for this user without requiring login
169+
favs_talks = SubmissionFavourite.objects.filter(user=user_id)
170+
if favs_talks.exists():
171+
exporter.talk_ids = list(
172+
favs_talks.values_list("submission_id", flat=True)
173+
)
174+
elif "-my" in exporter.identifier and self.request.user.id is None:
123175
if request.GET.get("talks"):
124176
exporter.talk_ids = request.GET.get("talks").split(",")
125177
else:
126178
return HttpResponseRedirect(self.request.event.urls.login)
127-
favs_talks = SubmissionFavourite.objects.filter(
128-
user=self.request.user.id
129-
)
130-
if favs_talks.exists():
131-
exporter.talk_ids = list(
132-
favs_talks.values_list("submission_id", flat=True)
179+
elif "-my" in exporter.identifier:
180+
favs_talks = SubmissionFavourite.objects.filter(
181+
user=self.request.user.id
133182
)
183+
if favs_talks.exists():
184+
exporter.talk_ids = list(
185+
favs_talks.values_list("submission_id", flat=True)
186+
)
187+
134188
exporter.is_orga = getattr(self.request, "is_orga", False)
135189

136190
try:
@@ -307,24 +361,51 @@ class ChangelogView(EventPermissionRequired, TemplateView):
307361

308362

309363
class GoogleCalendarRedirectView(EventPermissionRequired, ScheduleMixin, TemplateView):
364+
# Define constant for session key
365+
MY_STARRED_ICS_TOKEN_SESSION_KEY = 'my_starred_ics_token'
310366
permission_required = "agenda.view_schedule"
311367

312368
def get(self, request, *args, **kwargs):
313369
# Use resolver_match.url_name for robust route detection
314370
url_name = request.resolver_match.url_name if request.resolver_match else None
315371
if url_name == 'export.my-google-calendar':
316-
ics_name = 'schedule-my.ics'
372+
# Generate tokenized URL for my starred sessions
373+
if not request.user.is_authenticated:
374+
return HttpResponseRedirect(self.request.event.urls.login)
375+
376+
# Use constant instead of hardcoded string
377+
existing_token = request.session.get(self.MY_STARRED_ICS_TOKEN_SESSION_KEY)
378+
generate_new_token = True
379+
380+
# If we have an existing token, check if it's still valid and not expiring soon
381+
if existing_token:
382+
token_status = self.check_token_expiry(existing_token)
383+
if token_status is True:
384+
token = existing_token
385+
generate_new_token = False
386+
387+
# Generate a new token if needed
388+
if generate_new_token:
389+
token = self.generate_ics_token(request.user.id)
390+
# Use constant here too
391+
request.session[self.MY_STARRED_ICS_TOKEN_SESSION_KEY] = token
392+
393+
ics_url = request.build_absolute_uri(
394+
reverse('agenda:export-tokenized', kwargs={
395+
'event': self.request.event.slug,
396+
'name': 'schedule-my.ics',
397+
'token': token
398+
})
399+
)
317400
else:
318-
ics_name = 'schedule.ics'
319-
320-
# Build the iCal URL
321-
ics_url = request.build_absolute_uri(
322-
reverse('agenda:export', kwargs={
323-
'event': self.request.event.slug,
324-
'name': ics_name
325-
})
326-
)
327-
401+
# Regular public calendar
402+
ics_url = request.build_absolute_uri(
403+
reverse('agenda:export', kwargs={
404+
'event': self.request.event.slug,
405+
'name': 'schedule.ics'
406+
})
407+
)
408+
328409
# Change scheme to webcal
329410
parsed = urlparse(ics_url)
330411
ics_url = urlunparse(('webcal',) + parsed[1:])

0 commit comments

Comments
 (0)