Skip to content

Commit a6178e0

Browse files
Merge pull request #114 from alvin000009238/dev
Enhance share functionality with metadata, expiry options, and updates
2 parents 18b38f7 + 596bec5 commit a6178e0

7 files changed

Lines changed: 450 additions & 13 deletions

File tree

app/routes/share.py

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
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

33
from app.services.rate_limiter import is_rate_limited
44
from 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
)
1114
from app.services.turnstile_service import verify_turnstile_token
1215
import logging
1316

1417
logger = logging.getLogger('SchoolGradesServer.Share')
18+
SHARE_EXPIRY_OPTIONS = {
19+
'2h': 7200,
20+
'7d': 604800,
21+
}
1522

1623
bp = 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'])
69154
def 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

app/services/share_service.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import json
22
import secrets
33
import string
4+
from datetime import datetime, timezone
45

56
CHARS = string.ascii_letters + string.digits + '-_.~'
7+
SHARE_ID_LENGTH = 15
68
SHARE_MAX_PAYLOAD_BYTES = 512_000 # 500 KB
79

810

9-
def generate_share_id(length=15):
10-
return ''.join(secrets.choice(CHARS) for _ in range(length))
11+
def generate_share_id():
12+
return ''.join(secrets.choice(CHARS) for _ in range(SHARE_ID_LENGTH))
1113

1214

1315
def is_valid_share_id(share_id):
14-
return all(c in CHARS for c in share_id)
16+
return len(share_id) == SHARE_ID_LENGTH and all(c in CHARS for c in share_id)
1517

1618

1719
def validate_share_payload(data, max_bytes=SHARE_MAX_PAYLOAD_BYTES):
@@ -28,7 +30,7 @@ def validate_share_payload(data, max_bytes=SHARE_MAX_PAYLOAD_BYTES):
2830
return False, 'Payload 必須為 JSON 物件', None
2931

3032
# 剝離非必要欄位
31-
cleaned = {k: v for k, v in data.items() if k != 'turnstile_token'}
33+
cleaned = {k: v for k, v in data.items() if k not in ('turnstile_token', 'share_expiry')}
3234

3335
# 大小檢查
3436
serialized = json.dumps(cleaned, ensure_ascii=False)
@@ -51,10 +53,32 @@ def write_shared_data(redis_client, share_id, data, ttl=7200):
5153
redis_client.setex(cache_key, ttl, json.dumps(data, ensure_ascii=False))
5254

5355

56+
def write_share_metadata(redis_client, share_id, creator_student_no, ttl=7200):
57+
cache_key = f"share_meta:{share_id}"
58+
metadata = {
59+
'creator_student_no': creator_student_no,
60+
'created_at': datetime.now(timezone.utc).isoformat(),
61+
'ttl_seconds': ttl,
62+
}
63+
redis_client.setex(cache_key, ttl, json.dumps(metadata, ensure_ascii=False))
64+
65+
5466
def read_shared_data(redis_client, share_id):
5567
cache_key = f"share:{share_id}"
5668
data_str = redis_client.get(cache_key)
5769
if not data_str:
5870
return None
5971
return json.loads(data_str)
6072

73+
74+
def read_share_metadata(redis_client, share_id):
75+
cache_key = f"share_meta:{share_id}"
76+
data_str = redis_client.get(cache_key)
77+
if not data_str:
78+
return None
79+
return json.loads(data_str)
80+
81+
82+
def refresh_share_metadata_ttl(redis_client, share_id, ttl=7200):
83+
cache_key = f"share_meta:{share_id}"
84+
return redis_client.expire(cache_key, ttl) == 1

frontend/share.js

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,30 @@ import { initDashboard } from './dashboard.js';
88
import { emitOnboardingEvent, ONBOARDING_EVENTS } from './onboarding-events.js';
99
import { showAlert } from './dialog.js';
1010

11+
const ACTIVE_SHARE_ID_KEY = 'activeShareId';
12+
13+
export function getActiveShareId() {
14+
return localStorage.getItem(ACTIVE_SHARE_ID_KEY);
15+
}
16+
17+
export async function updateActiveShare(gradesData) {
18+
const shareId = getActiveShareId();
19+
if (!shareId || !gradesData) return { attempted: false, ok: false, status: null };
20+
21+
const res = await fetch(`/api/share/${shareId}`, {
22+
method: 'PUT',
23+
headers: { 'Content-Type': 'application/json' },
24+
credentials: 'include',
25+
body: JSON.stringify(gradesData)
26+
});
27+
28+
if (!res.ok && (res.status === 400 || res.status === 403 || res.status === 404)) {
29+
localStorage.removeItem(ACTIVE_SHARE_ID_KEY);
30+
}
31+
32+
return { attempted: true, ok: res.ok, status: res.status };
33+
}
34+
1135
export function setupShareFeature() {
1236
const shareBtn = document.getElementById('shareBtn');
1337
const shareModal = document.getElementById('shareModal');
@@ -21,9 +45,15 @@ export function setupShareFeature() {
2145
sharePreview.innerHTML = `
2246
<div class="share-modal-container">
2347
<p class="share-modal-text">
24-
建立一個唯讀的分享連結,讓他人查看此成績單。<br>
25-
連結將於 <strong class="text-warning">2 小時後</strong> 自動失效。
48+
建立一個唯讀的分享連結,讓他人查看此成績單。
2649
</p>
50+
<p class="share-modal-text mb-12">連結有效期限</p>
51+
<div class="form-group mb-12">
52+
<select id="shareExpirySelect" aria-label="連結有效期限" class="form-input">
53+
<option value="2h">2 小時</option>
54+
<option value="7d">7 天</option>
55+
</select>
56+
</div>
2757
<div id="linkContainer" class="share-link-container hidden">
2858
<div class="form-group mb-12">
2959
<input type="text" id="shareLinkInput" readonly
@@ -57,6 +87,7 @@ export function setupShareFeature() {
5787
const shareLinkInput = document.getElementById('shareLinkInput');
5888
const copyLinkBtn = document.getElementById('copyLinkBtn');
5989
const shareStatus = document.getElementById('shareStatus');
90+
const shareExpirySelect = document.getElementById('shareExpirySelect');
6091

6192
createLinkBtn.addEventListener('click', async () => {
6293
const gradesData = getStoredGrades();
@@ -82,16 +113,22 @@ export function setupShareFeature() {
82113
shareStatus.textContent = '';
83114

84115
try {
85-
const payload = { ...gradesData, turnstile_token: turnstileToken };
116+
const payload = {
117+
...gradesData,
118+
turnstile_token: turnstileToken,
119+
share_expiry: shareExpirySelect?.value || '2h'
120+
};
86121
const res = await fetch('/api/share', {
87122
method: 'POST',
88123
headers: { 'Content-Type': 'application/json' },
124+
credentials: 'include',
89125
body: JSON.stringify(payload)
90126
});
91127
const data = await res.json();
92128

93129
if (data.success) {
94130
const link = `${window.location.origin}/share/${data.id}`;
131+
localStorage.setItem(ACTIVE_SHARE_ID_KEY, data.id);
95132
shareLinkInput.value = link;
96133
linkContainer.style.display = 'block';
97134
createLinkBtn.style.display = 'none';

frontend/sync.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { requestTurnstileVerification } from './turnstile.js';
66
import { validateGradesData, storeGradesData } from './storage.js';
77
import { initDashboard } from './dashboard.js';
88
import { getDemoCredentials, getDemoResultData, getDemoStructure, isDemoModeEnabled } from './demo-mode.js';
9+
import { updateActiveShare } from './share.js';
910
import { emitOnboardingEvent, ONBOARDING_EVENTS } from './onboarding-events.js';
1011
import { showConfirm, showAlert } from './dialog.js';
1112

@@ -336,6 +337,9 @@ export function setupSyncFeature() {
336337
validateGradesData(data.data);
337338
storeGradesData(data.data);
338339
initDashboard(data.data);
340+
updateActiveShare(data.data).catch((err) => {
341+
console.error('Failed to update share', err);
342+
});
339343

340344
setTimeout(() => {
341345
toggleModal(selectExamModal, false);

tests/backend/test_share.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from app.services.share_service import generate_share_id, is_valid_share_id, validate_share_payload
22

33
def test_generate_share_id():
4-
sid = generate_share_id(15)
4+
sid = generate_share_id()
55
assert len(sid) == 15
66
assert is_valid_share_id(sid)
77

8+
9+
def test_is_valid_share_id_rejects_invalid_length():
10+
assert not is_valid_share_id('a' * 14)
11+
assert not is_valid_share_id('a' * 16)
12+
813
def test_validate_share_payload_invalid():
914
# Not a dict
1015
is_valid, err, _ = validate_share_payload([])
@@ -26,10 +31,12 @@ def test_validate_share_payload_valid():
2631
'Result': {
2732
'SubjectExamInfoList': []
2833
},
29-
'turnstile_token': 'abc'
34+
'turnstile_token': 'abc',
35+
'share_expiry': '7d'
3036
}
3137
is_valid, err, cleaned = validate_share_payload(payload)
3238
assert is_valid
3339
assert err is None
3440
assert 'turnstile_token' not in cleaned
41+
assert 'share_expiry' not in cleaned
3542
assert 'Result' in cleaned

0 commit comments

Comments
 (0)