Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions gas/Code.gs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,8 @@ function _verifyRecaptcha(token, expectedAction) {
* 已加入頻率限制:依 clientId 每人每分鐘最多 5 次,全體使用者每分鐘總計最多 20 次
* 已加入 reCAPTCHA v3 驗證:防止 GAS_URL 外洩後遭腳本大量濫用送出報修單
* 已加入後端格式驗證:比照前端 report.js 的驗證規則
* 已加入去重防護(BUG-DUP-01):120 秒內相同「學號+房號+問題描述」的重複請求
* 將直接回傳 DUPLICATE_REPORT,防止使用者或腳本在 2 分鐘內重複送出相同報修單
*
* @param {object} reportData - 報修資料物件
* @param {string} [clientId] - 前端產生的裝置識別碼(非個資),用於依使用者區分限流
Expand Down Expand Up @@ -521,6 +523,22 @@ function writeReport(reportData, clientId, recaptchaToken) {
return { success: false, error: 'VALIDATION_DESCRIPTION_REQUIRED' };
}

// ── 去重防護(BUG-DUP-01):120 秒內相同「學號 + 房號 + 描述前 50 字」禁止重複送出 ──
// 目的:防止使用者在送出後不小心(或惡意)在 2 分鐘內重複送出完全相同的報修單。
// 做法:以學號、房號、描述前 50 字組成字串,使用 MD5 雜湊後轉為 Base64WebSafe 作為 CacheService key,
// 首次成功寫入後存入快取 120 秒;快取命中則直接拒絕。
// 注意:此檢查在格式驗證通過後、寫入試算表前執行;
// 寫入成功後才真正寫入快取,避免驗證失敗的請求佔用去重配額。
const _dupCache = CacheService.getScriptCache();
const _dupRaw = `${studentId}_${roomNumber}_${description.substring(0, 50)}`;
const _dupBytes = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, _dupRaw);
const _dupKey = 'dedup_' + Utilities.base64EncodeWebSafe(_dupBytes);

if (_dupCache.get(_dupKey)) {
Logger.log(`[writeReport] 攔截重複報修,指紋命中:${_dupKey}`);
return { success: false, error: 'DUPLICATE_REPORT' };
}

const spreadsheet = SpreadsheetApp.openById(_getSpreadsheetId());
const sheet = spreadsheet.getSheetByName(SHEET_NAME);

Expand Down Expand Up @@ -583,6 +601,9 @@ function writeReport(reportData, clientId, recaptchaToken) {
targetRange.setNumberFormat('@'); // ① 先設成純文字格式
targetRange.setValues([rowValues]); // ② 再寫入(取代 appendRow)

// 寫入成功後,將指紋存入快取 120 秒(2 分鐘),防止重複送出
_dupCache.put(_dupKey, '1', 120);

Logger.log(`[writeReport] 新增報修:${reportData.name} ${reportData.roomNumber}-${reportData.bedNumber}`);
return { success: true, message: '報修資料已成功寫入試算表' };

Expand Down
6 changes: 4 additions & 2 deletions js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ const CONFIG = Object.freeze({
REPAIR_TIME_RANGE: '可維修時間必須在 0–23 時與 0–59 分之間',
DESCRIPTION_REQUIRED: '請描述您的網路問題',
QUERY_STUDENT_ID_REQUIRED: '請輸入學號',
QUERY_STUDENT_ID_FORMAT: '學號格式錯誤(需為 1 位英文字母 + 7 位數字,例如:D1234567)'
QUERY_STUDENT_ID_FORMAT: '學號格式錯誤(需為 1 位英文字母 + 7 位數字,例如:D1234567)',
DUPLICATE_REPORT: '您剛剛已送出過相同的報修單,請勿在 2 分鐘內重複送出。若確認報修已登錄,可使用「查詢案件」功能查看。'
})
}),
en: Object.freeze({
Expand Down Expand Up @@ -164,7 +165,8 @@ const CONFIG = Object.freeze({
REPAIR_TIME_RANGE: 'Available time must be between 0-23 hours and 0-59 minutes',
DESCRIPTION_REQUIRED: 'Please describe your network issue',
QUERY_STUDENT_ID_REQUIRED: 'Please enter your Student ID',
QUERY_STUDENT_ID_FORMAT: 'Invalid Student ID (Format: 1 letter + 7 digits, e.g. D1234567)'
QUERY_STUDENT_ID_FORMAT: 'Invalid Student ID (Format: 1 letter + 7 digits, e.g. D1234567)',
DUPLICATE_REPORT: 'You just submitted an identical repair request. Please do not resubmit within 2 minutes. To confirm your request was recorded, use the “Check My Cases” feature.'
})
})
}),
Expand Down
4 changes: 4 additions & 0 deletions js/report.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ const ReportForm = (() => {
const _friendlyError = (err) => {
if (!_isInternalCode(err)) return err || _R().REPORT_ERROR;
if (err === 'RATE_LIMITED') return _R().SYSTEM_ERROR;
// DUPLICATE_REPORT:去重防護觸發,屬使用者操作錯誤而非系統錯誤,顯示友善提示
if (err === 'DUPLICATE_REPORT' && _R().VALIDATION.DUPLICATE_REPORT) {
return _R().VALIDATION.DUPLICATE_REPORT;
}
if (err.startsWith('VALIDATION_')) {
const key = err.replace('VALIDATION_', '');
if (_R().VALIDATION[key]) return _R().VALIDATION[key];
Expand Down
41 changes: 41 additions & 0 deletions test/gas-code.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,47 @@ test('writeReport:超過個人頻率限制時直接拒絕(不觸發 reCAPTCH
}
});

// ══════════════════════════════════════════════
// 3b. 去重防護(BUG-DUP-01)迴歸測試
// ══════════════════════════════════════════════
test('writeReport:120 秒內相同內容重複送出應回傳 DUPLICATE_REPORT(BUG-DUP-01 迴歸測試)', () => {
const { exported, mocks, restore } = loadGasCode({
scriptProperties: { SPREADSHEET_ID: 'sheet-id', RECAPTCHA_SECRET_KEY: 'secret' }
});
try {
mockPassingRecaptcha(mocks);
// 第一次:應該成功
const first = exported.writeReport(validReportPayload(), 'user-dup', 'token-1');
assert.equal(first.success, true, '第一次送出應成功');

// 第二次送出完全相同的內容(不同 clientId 也應被攔截,去重依內容不依使用者)
const second = exported.writeReport(validReportPayload(), 'user-dup2', 'token-2');
assert.equal(second.success, false, '相同內容第二次應被拒絕');
assert.match(second.error, /DUPLICATE_REPORT/, '錯誤碼應為 DUPLICATE_REPORT');
} finally {
restore();
}
});

test('writeReport:描述不同時不視為重複,應允許送出', () => {
const { exported, mocks, restore } = loadGasCode({
scriptProperties: { SPREADSHEET_ID: 'sheet-id', RECAPTCHA_SECRET_KEY: 'secret' }
});
try {
mockPassingRecaptcha(mocks);
const first = exported.writeReport(validReportPayload(), 'user-diff-1', 'token-a');
assert.equal(first.success, true, '第一次送出應成功');

// 描述不同 → 不視為重複
const diffPayload = { ...validReportPayload(), description: '不同的問題描述' };
const second = exported.writeReport(diffPayload, 'user-diff-2', 'token-b');
assert.equal(second.success, true, '描述不同的報修應允許通過');
} finally {
restore();
}
});


// ══════════════════════════════════════════════
// 4. classifyIntent:頻率限制 + 未設定 API Key 時的行為
// ══════════════════════════════════════════════
Expand Down
5 changes: 4 additions & 1 deletion test/gas-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ function createGasMocks(overrides = {}) {
const Utilities = {
getUuid: () => crypto.randomUUID(),
sleep: () => {}, // 測試中不需要真的等待
formatDate: (date) => date.toISOString()
formatDate: (date) => date.toISOString(),
computeDigest: (algo, val) => crypto.createHash('md5').update(String(val)).digest(),
DigestAlgorithm: { MD5: 'MD5' },
base64EncodeWebSafe: (bytes) => Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
};

const ContentService = {
Expand Down
Loading