Files
online-attendance/Code.gs
2026-08-25 11:14:55 +09:00

172 lines
6.5 KiB
JavaScript

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 };
}