1- from flask import Blueprint , current_app , jsonify , request , send_from_directory
1+ from flask import Blueprint , current_app , jsonify , request , send_from_directory , session
22
33from app .services .rate_limiter import is_rate_limited
44from app .services .share_service import (
55 generate_share_id ,
66 is_valid_share_id ,
7+ refresh_share_metadata_ttl ,
8+ read_share_metadata ,
79 read_shared_data ,
810 validate_share_payload ,
11+ write_share_metadata ,
912 write_shared_data ,
1013)
1114from app .services .turnstile_service import verify_turnstile_token
1215import logging
1316
1417logger = logging .getLogger ('SchoolGradesServer.Share' )
18+ SHARE_EXPIRY_OPTIONS = {
19+ '2h' : 7200 ,
20+ '7d' : 604800 ,
21+ }
1522
1623bp = Blueprint ('share' , __name__ )
1724
@@ -23,6 +30,14 @@ def create_share_link():
2330 if not data or not isinstance (data , dict ):
2431 return jsonify ({'error' : 'No data provided or invalid format' }), 400
2532
33+ student_no = session .get ('student_no' )
34+ if not student_no :
35+ return jsonify ({'error' : 'Unauthorized' }), 401
36+
37+ redis_client = current_app .config .get ('REDIS_CLIENT' )
38+ if redis_client is None :
39+ return jsonify ({'error' : 'Share service unavailable' }), 503
40+
2641 # Turnstile 人機驗證
2742 ts_ok , ts_err = verify_turnstile_token (
2843 data .get ('turnstile_token' ),
@@ -32,7 +47,6 @@ def create_share_link():
3247 return jsonify ({'error' : ts_err }), 403
3348
3449 # 速率限制檢查(在 Turnstile 之後)
35- redis_client = current_app .config .get ('REDIS_CLIENT' )
3650 if redis_client :
3751 try :
3852 # 較寬鬆的速率限制:每小時 (3600 秒) 10 次
@@ -57,21 +71,96 @@ def create_share_link():
5771 if not valid :
5872 return jsonify ({'error' : err }), 400
5973
74+ requested_expiry = data .get ('share_expiry' , '2h' )
75+ if requested_expiry not in SHARE_EXPIRY_OPTIONS :
76+ return jsonify ({'error' : 'Invalid share expiry option' }), 400
77+
6078 share_id = generate_share_id ()
61- write_shared_data (current_app .config ['REDIS_CLIENT' ], share_id , cleaned , current_app .config ['SHARE_TTL' ])
79+ share_ttl = SHARE_EXPIRY_OPTIONS [requested_expiry ]
80+ write_shared_data (redis_client , share_id , cleaned , share_ttl )
81+ write_share_metadata (redis_client , share_id , student_no , share_ttl )
6282 return jsonify ({'success' : True , 'id' : share_id })
6383 except Exception as exc :
6484 logger .error (f'Error creating share: { exc } ' , exc_info = True )
6585 return jsonify ({'error' : str (exc )}), 500
6686
6787
88+ @bp .route ('/api/share/<share_id>' , methods = ['PUT' ])
89+ def update_share_link (share_id ):
90+ try :
91+ if not is_valid_share_id (share_id ):
92+ return jsonify ({'error' : 'Invalid ID format' }), 400
93+
94+ data = request .json
95+ if not data or not isinstance (data , dict ):
96+ return jsonify ({'error' : 'No data provided or invalid format' }), 400
97+
98+ redis_client = current_app .config .get ('REDIS_CLIENT' )
99+ if redis_client is None :
100+ return jsonify ({'error' : 'Share service unavailable' }), 503
101+
102+ requester_student_no = session .get ('student_no' )
103+ if requester_student_no is None :
104+ return jsonify ({'error' : 'Unauthorized' }), 401
105+
106+ rate_limit_subject = requester_student_no or request .remote_addr
107+ if redis_client :
108+ try :
109+ limited , remaining , retry_after = is_rate_limited (
110+ redis_client ,
111+ rate_limit_subject ,
112+ max_attempts = 10 ,
113+ window_seconds = 60 ,
114+ key_prefix = 'share_update'
115+ )
116+ if limited :
117+ resp = jsonify ({
118+ 'error' : f'更新分享過於頻繁,請在 { retry_after } 秒後再試' ,
119+ })
120+ resp .headers ['Retry-After' ] = str (retry_after )
121+ return resp , 429
122+ except Exception as exc :
123+ logger .error (f'Share update rate limiter error: { exc } ' , exc_info = True )
124+
125+ metadata = read_share_metadata (redis_client , share_id )
126+ if metadata is None :
127+ return jsonify ({'error' : 'Link expired or not found' }), 404
128+
129+ creator_student_no = metadata .get ('creator_student_no' )
130+ if not creator_student_no :
131+ return jsonify ({'error' : 'Forbidden' }), 403
132+
133+ if requester_student_no != creator_student_no :
134+ return jsonify ({'error' : 'Forbidden' }), 403
135+
136+ valid , err , cleaned = validate_share_payload (data )
137+ if not valid :
138+ return jsonify ({'error' : err }), 400
139+
140+ share_ttl = metadata .get ('ttl_seconds' , current_app .config ['SHARE_TTL' ])
141+ if share_ttl not in SHARE_EXPIRY_OPTIONS .values ():
142+ return jsonify ({'error' : 'Share metadata state conflict' }), 409
143+ metadata_ttl_refreshed = refresh_share_metadata_ttl (redis_client , share_id , share_ttl )
144+ if not metadata_ttl_refreshed :
145+ return jsonify ({'error' : 'Share metadata state conflict' }), 409
146+ write_shared_data (redis_client , share_id , cleaned , share_ttl )
147+ return jsonify ({'success' : True , 'id' : share_id })
148+ except Exception as exc :
149+ logger .error (f'Error updating share: { exc } ' , exc_info = True )
150+ return jsonify ({'error' : str (exc )}), 500
151+
152+
68153@bp .route ('/api/share/<share_id>' , methods = ['GET' ])
69154def get_shared_grades (share_id ):
70155 try :
71156 if not is_valid_share_id (share_id ):
72157 return jsonify ({'error' : 'Invalid ID format' }), 400
73158
74- data = read_shared_data (current_app .config ['REDIS_CLIENT' ], share_id )
159+ redis_client = current_app .config .get ('REDIS_CLIENT' )
160+ if redis_client is None :
161+ return jsonify ({'error' : 'Share service unavailable' }), 503
162+
163+ data = read_shared_data (redis_client , share_id )
75164 if data is None :
76165 return jsonify ({'error' : 'Link expired or not found' }), 404
77166
0 commit comments