const SHEET_NAME = '출석부'; const SIGNATURE_FOLDER_NAME = '출석_서명이미지'; const HEADERS = ['날짜', '이름', '입실시각', '퇴실시각', '학습시간', '입실서명', '퇴실서명', '입실기기ID', '입실기기정보', '퇴실기기ID', '퇴실기기정보']; const COL = { DATE: 1, NAME: 2, IN_TIME: 3, OUT_TIME: 4, STUDY_TIME: 5, IN_SIGNATURE: 6, OUT_SIGNATURE: 7, IN_DEVICE_ID: 8, IN_DEVICE_INFO: 9, OUT_DEVICE_ID: 10, OUT_DEVICE_INFO: 11 }; const MAX_SIGNATURE_BASE64_LENGTH = 2000000; // 서명 이미지 base64 길이 상한(약 1.5MB), 남용 방지용 // 학생용 페이지는 이 Apps Script가 아니라 별도 정적 호스팅(예: GitHub Pages)에 둔다. // 이 웹앱은 그 페이지가 fetch()로 호출하는 JSON API 역할만 한다 (배포 URL을 직접 열람하지 않게 하기 위함). function doGet(e) { if (e && e.parameter && e.parameter.action === 'names') { return ContentService.createTextOutput(JSON.stringify({ ok: true, names: getKnownNames_() })) .setMimeType(ContentService.MimeType.JSON); } return ContentService.createTextOutput('출석부 API가 정상 동작 중입니다.'); } // 이름 자동완성용: 시트에 이미 기록된 이름을 중복 없이 반환한다. function getKnownNames_() { const sheet = getOrCreateSheet_(); const lastRow = sheet.getLastRow(); if (lastRow < 2) return []; const values = sheet.getRange(2, COL.NAME, lastRow - 1, 1).getValues(); const seen = {}; const names = []; values.forEach(function (row) { const n = row[0]; if (n && !seen[n]) { seen[n] = true; names.push(n); } }); return names; } function doPost(e) { let result; try { const payload = JSON.parse(e.postData.contents); result = submitAttendance(payload); } catch (err) { result = { ok: false, error: err.message }; } return ContentService.createTextOutput(JSON.stringify(result)) .setMimeType(ContentService.MimeType.JSON); } function getOrCreateSheet_() { const ss = SpreadsheetApp.getActiveSpreadsheet(); let sheet = ss.getSheetByName(SHEET_NAME); if (!sheet) { sheet = ss.insertSheet(SHEET_NAME); } if (sheet.getLastRow() === 0) { sheet.appendRow(HEADERS); sheet.setFrozenRows(1); sheet.getRange('A2:A').setNumberFormat('yyyy-mm-dd'); sheet.getRange('C2:D').setNumberFormat('hh:mm:ss'); sheet.getRange('E2:E').setNumberFormat('[h]"시간" mm"분"'); sheet.setColumnWidth(COL.IN_SIGNATURE, 160); sheet.setColumnWidth(COL.OUT_SIGNATURE, 160); sheet.hideColumns(COL.IN_DEVICE_ID, 4); } return sheet; } function getOrCreateFolder_() { const folders = DriveApp.getFoldersByName(SIGNATURE_FOLDER_NAME); if (folders.hasNext()) { return folders.next(); } return DriveApp.createFolder(SIGNATURE_FOLDER_NAME); } // 이름/기기정보가 "=", "+" 등으로 시작하면 시트에서 수식으로 실행될 수 있어(수식 삽입 취약점) 앞에 작은따옴표를 붙여 텍스트로 강제 function sanitizeForSheet_(text) { const str = (text || '').toString(); return /^[=+\-@\t\r]/.test(str) ? "'" + str : str; } // 같은 이름 + 같은 날짜의 행을 찾아 반환하고, 없으면 새로 만들어서 반환한다. function findOrCreateRow_(sheet, dateStr, name) { const lastRow = sheet.getLastRow(); const tz = Session.getScriptTimeZone(); if (lastRow >= 2) { const values = sheet.getRange(2, COL.DATE, lastRow - 1, 2).getValues(); for (let i = 0; i < values.length; i++) { const rowDate = values[i][0]; const rowDateStr = rowDate instanceof Date ? Utilities.formatDate(rowDate, tz, 'yyyy-MM-dd') : ''; if (rowDateStr === dateStr && values[i][1] === name) { return i + 2; } } } const newRow = lastRow + 1; sheet.getRange(newRow, COL.DATE).setValue(new Date(dateStr)); sheet.getRange(newRow, COL.NAME).setValue(sanitizeForSheet_(name)); // 입실/퇴실시각이 둘 다 채워지면 자동으로 학습시간을 계산하는 수식 (둘 중 하나라도 없으면 빈 값) sheet.getRange(newRow, COL.STUDY_TIME).setFormula( '=IF(AND(C' + newRow + '<>"",D' + newRow + '<>""),D' + newRow + '-C' + newRow + ',"")' ); return newRow; } function submitAttendance(payload) { if (!payload || !payload.name || !payload.signature) { throw new Error('이름과 서명을 모두 입력해주세요.'); } if (payload.type !== 'in' && payload.type !== 'out') { throw new Error('입실/퇴실 여부를 선택해주세요.'); } const name = payload.name.toString().trim().slice(0, 50); if (!name) { throw new Error('이름을 입력해주세요.'); } const base64 = payload.signature.split(',').pop(); if (!base64 || base64.length > MAX_SIGNATURE_BASE64_LENGTH) { throw new Error('서명 데이터가 올바르지 않습니다.'); } const now = new Date(); const tz = Session.getScriptTimeZone(); const dateStr = Utilities.formatDate(now, tz, 'yyyy-MM-dd'); const sheet = getOrCreateSheet_(); const row = findOrCreateRow_(sheet, dateStr, name); const isIn = payload.type === 'in'; const timeCol = isIn ? COL.IN_TIME : COL.OUT_TIME; const existing = sheet.getRange(row, timeCol).getValue(); const shouldUpdate = !(existing instanceof Date) || (isIn ? now < existing : now > existing); // 입실은 그날의 가장 빠른 시각, 퇴실은 가장 늦은 시각만 채택한다. // 채택되지 않는 제출은 시각/서명/기기정보를 아예 기록하지 않는다 (기록된 시각과 서명이 항상 같은 제출에서 나오도록 보장). if (!shouldUpdate) { return { ok: true, updated: false }; } const bytes = Utilities.base64Decode(base64); const blob = Utilities.newBlob(bytes, 'image/png', name + '_' + now.getTime() + '.png'); const folder = getOrCreateFolder_(); const file = folder.createFile(blob); file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); const imageUrl = 'https://drive.google.com/thumbnail?id=' + file.getId() + '&sz=w1000'; const sigCol = isIn ? COL.IN_SIGNATURE : COL.OUT_SIGNATURE; const devIdCol = isIn ? COL.IN_DEVICE_ID : COL.OUT_DEVICE_ID; const devInfoCol = isIn ? COL.IN_DEVICE_INFO : COL.OUT_DEVICE_INFO; sheet.getRange(row, timeCol).setValue(now); const cellImage = SpreadsheetApp.newCellImage().setSourceUrl(imageUrl).build(); sheet.getRange(row, sigCol).setValue(cellImage); sheet.getRange(row, devIdCol).setValue(sanitizeForSheet_(payload.deviceId)); sheet.getRange(row, devInfoCol).setValue(sanitizeForSheet_(payload.deviceInfo)); sheet.setRowHeight(row, 60); return { ok: true, updated: true }; }