44import textwrap
55from contextlib import suppress
66from urllib .parse import unquote , urlencode , urlparse , urlunparse
7+ from datetime import timedelta
78
89from django .contrib import messages
10+ from django .core import signing
11+ from django .utils import timezone
912from 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
69108class 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
309363class 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