Skip to content

Commit 987884b

Browse files
feat: /api/share 加入 Turnstile 人機驗證,並提取共用驗證函式
1 parent 0ff1479 commit 987884b

2 files changed

Lines changed: 77 additions & 21 deletions

File tree

grades_dashboard.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,7 @@ function setupShareFeature() {
11011101
</svg>
11021102
建立分享連結
11031103
</button>
1104+
<div id="shareTurnstileContainer" style="display: flex; justify-content: center; margin-top: 12px;"></div>
11041105
<div id="shareStatus" class="status-msg"></div>
11051106
</div>
11061107
`;
@@ -1123,11 +1124,52 @@ function setupShareFeature() {
11231124
createLinkBtn.textContent = '建立中...';
11241125
shareStatus.textContent = '';
11251126

1127+
// 取得 Turnstile token
1128+
let turnstileResponse = '';
1129+
if (typeof turnstile !== 'undefined') {
1130+
const shareTurnstileContainer = document.getElementById('shareTurnstileContainer');
1131+
if (shareTurnstileContainer) {
1132+
// 渲染 widget (如果尚未渲染)
1133+
if (!shareTurnstileContainer.dataset.widgetId) {
1134+
try {
1135+
const siteKeyRes = await fetch('/api/turnstile-site-key');
1136+
const siteKeyData = await siteKeyRes.json();
1137+
const siteKey = siteKeyData.siteKey;
1138+
if (siteKey) {
1139+
const widgetId = turnstile.render(shareTurnstileContainer, {
1140+
sitekey: siteKey,
1141+
theme: 'dark',
1142+
});
1143+
shareTurnstileContainer.dataset.widgetId = widgetId;
1144+
// 等待用戶完成驗證
1145+
createLinkBtn.disabled = false;
1146+
createLinkBtn.textContent = '建立分享連結';
1147+
shareStatus.textContent = '請先完成人機驗證';
1148+
shareStatus.className = 'status-msg';
1149+
return;
1150+
}
1151+
} catch (e) {
1152+
console.warn('Failed to render share Turnstile:', e);
1153+
}
1154+
} else {
1155+
turnstileResponse = turnstile.getResponse(shareTurnstileContainer.dataset.widgetId) || '';
1156+
if (!turnstileResponse) {
1157+
shareStatus.textContent = '請完成人機驗證';
1158+
shareStatus.className = 'status-msg error';
1159+
createLinkBtn.disabled = false;
1160+
createLinkBtn.textContent = '建立分享連結';
1161+
return;
1162+
}
1163+
}
1164+
}
1165+
}
1166+
11261167
try {
1168+
const payload = { ...gradesData, 'cf-turnstile-response': turnstileResponse };
11271169
const res = await fetch('/api/share', {
11281170
method: 'POST',
11291171
headers: { 'Content-Type': 'application/json' },
1130-
body: JSON.stringify(gradesData)
1172+
body: JSON.stringify(payload)
11311173
});
11321174
const data = await res.json();
11331175

server.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,31 @@
5656
TURNSTILE_SECRET_KEY = os.environ.get('TURNSTILE_SECRET_KEY', '')
5757
TURNSTILE_SITE_KEY = os.environ.get('TURNSTILE_SITE_KEY', '')
5858

59+
def verify_turnstile(token, remote_ip, context=''):
60+
"""共用 Turnstile 驗證函式。回傳 (success: bool, error_response or None)。"""
61+
if not TURNSTILE_SECRET_KEY:
62+
return True, None
63+
if not token:
64+
return False, (jsonify({'success': False, 'message': '請完成人機驗證'}), 400)
65+
try:
66+
verify_res = http_requests.post(
67+
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
68+
data={
69+
'secret': TURNSTILE_SECRET_KEY,
70+
'response': token,
71+
'remoteip': remote_ip
72+
},
73+
timeout=10
74+
)
75+
verify_data = verify_res.json()
76+
if not verify_data.get('success'):
77+
logger.warning(f"Turnstile verification failed ({context}): {verify_data}")
78+
return False, (jsonify({'success': False, 'message': '人機驗證失敗,請重試'}), 403)
79+
except Exception as e:
80+
logger.error(f"Turnstile verification error ({context}): {e}")
81+
return False, (jsonify({'success': False, 'message': '驗證服務暫時無法使用,請稍後再試'}), 500)
82+
return True, None
83+
5984

6085
if not os.path.exists(SHARED_FOLDER):
6186
os.makedirs(SHARED_FOLDER)
@@ -129,26 +154,9 @@ def login():
129154

130155
# Turnstile 驗證
131156
turnstile_token = data.get('cf-turnstile-response', '')
132-
if TURNSTILE_SECRET_KEY:
133-
if not turnstile_token:
134-
return jsonify({'success': False, 'message': '請完成人機驗證'}), 400
135-
try:
136-
verify_res = http_requests.post(
137-
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
138-
data={
139-
'secret': TURNSTILE_SECRET_KEY,
140-
'response': turnstile_token,
141-
'remoteip': request.remote_addr
142-
},
143-
timeout=10
144-
)
145-
verify_data = verify_res.json()
146-
if not verify_data.get('success'):
147-
logger.warning(f"Turnstile verification failed for user {username}: {verify_data}")
148-
return jsonify({'success': False, 'message': '人機驗證失敗,請重試'}), 403
149-
except Exception as e:
150-
logger.error(f"Turnstile verification error: {e}")
151-
return jsonify({'success': False, 'message': '驗證服務暫時無法使用,請稍後再試'}), 500
157+
ok, err = verify_turnstile(turnstile_token, request.remote_addr, context=f'login:{username}')
158+
if not ok:
159+
return err
152160

153161
try:
154162
success, message, cookies, student_no, token = GradeFetcher.login_and_get_tokens(username, data.get('password'))
@@ -262,6 +270,12 @@ def create_share_link():
262270
if not data:
263271
return jsonify({'error': 'No data provided'}), 400
264272

273+
# Turnstile 驗證
274+
turnstile_token = data.pop('cf-turnstile-response', '')
275+
ok, err = verify_turnstile(turnstile_token, request.remote_addr, context='share')
276+
if not ok:
277+
return err
278+
265279
# Generate 15-char random URL-safe ID with special chars
266280
# Using A-Z, a-z, 0-9, -, _, ., ~
267281
chars = string.ascii_letters + string.digits + "-_.~"

0 commit comments

Comments
 (0)