diff --git a/Code.gs b/Code.gs deleted file mode 100644 index c09ccfa..0000000 --- a/Code.gs +++ /dev/null @@ -1,171 +0,0 @@ -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 }; -} diff --git a/README.md b/README.md deleted file mode 100644 index 65f5e02..0000000 --- a/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# 온라인 출석부 (QR 서명 방식) - -## 구조 - -구글 계정이 여러 개 있는 폰에서 `script.google.com` 페이지를 직접 열면, 구글이 계정 컨텍스트를 정하는 과정에서 주소가 `.../u/1/exec`, `.../u/3/exec` 처럼 바뀌면서 접속이 안 되는 문제가 있습니다. 이 문제는 링크를 단축 URL로 감싸도, 최종적으로 그 페이지를 "열람"하는 순간 다시 발생합니다. - -그래서 이 구성에서는 **학생이 여는 페이지는 구글 도메인이 아닌 곳에 두고**, Apps Script는 그 페이지가 백그라운드에서 `fetch()`로 호출하는 JSON API 역할만 하도록 나눴습니다. 브라우저가 `script.google.com`을 화면에 열람하는 게 아니라 JS가 조용히 API만 호출하기 때문에 계정 선택 문제가 아예 발생하지 않습니다. - -- `Code.gs` → Google Apps Script에 붙여넣음. 서명 이미지 저장 + 시트 기록을 담당하는 JSON API (`doPost`) -- `site/index.html` → **Apps Script가 아닌** 일반 정적 호스팅(GitHub Pages 등)에 올림. 학생이 QR로 여는 실제 페이지 (이름 입력 + 손글씨 서명 캔버스) - -기록되는 열: 날짜, 이름, 입실시각, 퇴실시각, 학습시간(자동 계산), 입실서명(이미지), 퇴실서명(이미지), 입실기기ID·입실기기정보·퇴실기기ID·퇴실기기정보(기본적으로 숨김 처리됨) - -- **학습시간**은 입실시각·퇴실시각이 둘 다 있을 때만 자동 계산되는 수식(`=D-C`)입니다. 시각이 바뀌면 자동으로 다시 계산됩니다. -- **이름 입력창 자동완성**: 페이지를 열 때 시트에 이미 기록된 이름 목록을 불러와 입력창에 제안으로 띄워줍니다 (브라우저 기본 자동완성 UI 사용, 새 이름도 자유롭게 입력 가능). -- 페이지 상단에 학교 로고 이미지가 표시됩니다. - -시트는 **학생 1명 + 날짜 1개 = 1행**으로 관리됩니다. 같은 날 같은 학생이 여러 번 서명해도 행이 늘어나지 않고, 다음 규칙으로 같은 행이 갱신됩니다. -- **입실**: 그날 기록된 시각보다 더 빠른 경우에만 갱신 (그날 첫 입실 시각 유지) -- **퇴실**: 그날 기록된 시각보다 더 늦은 경우에만 갱신 (그날 마지막 퇴실 시각 유지) -- 갱신되지 않는 제출은 서명 이미지도 기록되지 않습니다 (시트에 남는 서명은 항상 실제로 채택된 입실/퇴실 시각과 같은 순간에 찍힌 서명입니다). - -이 구조라서 날짜별로 등교/하교 시간을 볼 때 피벗테이블이 단순해집니다 — 이미 한 줄에 그날의 입실/퇴실시각이 정리돼 있으므로, 피벗테이블에서 행에 `날짜`(또는 `이름`), 값에 `입실시각`/`퇴실시각`을 그대로 넣으면 됩니다 (별도 MIN/MAX 집계 설정 불필요). - -> **열 구조가 바뀔 때마다 기존 `출석부` 시트는 그대로 재사용하면 안 됩니다** (헤더가 이미 있으면 코드가 헤더를 다시 쓰지 않기 때문에 새 열이 추가되지 않습니다). 학습시간 열을 추가하면서 다시 한 번 열 구조가 바뀌었으니, 기존 `출석부` 탭 이름을 `출석부_구버전` 등으로 바꿔 보관해두고 Apps Script를 재배포하면 새 구조의 `출석부` 시트가 새로 만들어집니다. - -## 1단계 — Apps Script API 배포 - -1. [sheets.google.com](https://sheets.google.com)에서 새 스프레드시트 생성 (이름 예: "출석부") -2. 상단 메뉴 **확장 프로그램 → Apps Script** 클릭 -3. 기본 생성된 `Code.gs` 내용을 전부 지우고, 이 폴더의 `Code.gs` 내용을 붙여넣기 -4. `Ctrl+S`로 저장 -5. 오른쪽 위 **배포 → 새 배포** - - 유형: **웹 앱** - - 실행 계정: **나** - - 액세스 권한이 있는 사용자: **전체** - - 배포 클릭 -6. 처음 배포 시 권한 승인 창이 뜨면 **고급 → (프로젝트명)으로 이동 → 허용** (Sheets/Drive 접근 권한 승인 — 본인 계정으로만 실행되므로 안전합니다) -7. 발급된 웹 앱 URL(`https://script.google.com/macros/s/.../exec`)을 복사 — **이 URL은 학생에게 직접 주지 않습니다.** 2단계에서 페이지 안에만 넣습니다. - -## 2단계 — 학생용 페이지 호스팅 (구글이 아닌 곳) - -`site/index.html`을 열어 맨 위 스크립트의 - -```js -const EXEC_URL = 'PASTE_YOUR_APPS_SCRIPT_WEB_APP_URL_HERE'; -``` - -부분을 1단계에서 복사한 실제 URL로 교체한 뒤, 정적 호스팅에 올립니다. 아무 계정 없이 가장 빨리 해보려면: - -- **Netlify Drop**: [app.netlify.com/drop](https://app.netlify.com/drop)에 `site` 폴더를 그대로 드래그 앤 드롭 → 즉시 URL 발급 (계정 없이도 되지만, 계속 쓰려면 무료 계정 연결 권장) - -계속 쓸 주소를 원하면: - -- **GitHub Pages**: GitHub 저장소에 `site` 폴더 내용을 올리고 저장소 설정에서 Pages를 켜면 `https://<사용자명>.github.io/<저장소명>/` 형태의 고정 주소가 생김 -- **Cloudflare Pages / Vercel**: 같은 방식으로 `site` 폴더 하나만 배포하면 됨 (별도 빌드 설정 불필요, 정적 HTML 파일 1개) - -## 3단계 — QR 코드 만들기 - -호스팅에서 받은 주소(구글 도메인이 아닌 주소)로 QR 코드를 생성해 인쇄 후 교실 입구에 부착하세요. -- Chrome 주소창 오른쪽 **공유 아이콘 → "QR 코드 만들기"** 기능을 쓰면 별도 사이트 없이 바로 생성됩니다. -- 원하는 QR 생성 사이트/앱을 사용해도 무방합니다. - -이제 QR을 찍으면 곧바로 `site/index.html` 페이지가 열리고(구글 계정과 무관), 제출 시에만 뒤에서 조용히 Apps Script API를 호출합니다. - -## 대리서명 확인 방법 - -- **입실기기ID/퇴실기기ID** 열(기본 숨김)이 같은데 **이름**이 다른 경우, 같은 기기(브라우저)에서 여러 사람 이름으로 서명이 이뤄진 것이므로 대리서명 의심 대상입니다. 확인할 때만 열을 펼쳐서(선택 후 우클릭 → 열 표시) 기기ID 기준으로 정렬하거나 조건부 서식으로 중복을 표시하면 됩니다. -- **입실기기정보/퇴실기기정보** 열로 기종·브라우저가 같은 서명이 몰려 있는지 함께 확인하세요. -- 기기ID는 브라우저의 localStorage에 저장되는 값이라, 시크릿 모드나 브라우저 데이터 삭제 시 매번 새로 생성됩니다. 완벽한 기기 식별은 아니고 보조 지표로만 활용하세요. - -## 피벗테이블로 일자별 등교/하교 시간 보기 - -1. `출석부` 시트에서 데이터 범위를 선택하고 **삽입 → 피벗 테이블** -2. 행: `날짜` (필요하면 `이름`도 추가) -3. 값: `입실시각`, `퇴실시각` — 요약 방식은 "없음"(그대로 표시)이나 "최소"/"최대" 어느 걸 골라도 결과가 같습니다 (한 학생·한 날짜에 이미 값이 하나뿐이기 때문입니다). - -## 코드 수정 시 주의 - -- `Code.gs`를 수정한 뒤에는 **배포 → 배포 관리 → 수정(연필 아이콘) → 버전: 새 버전 → 배포**를 해야 반영됩니다. (같은 URL이 유지되므로 `site/index.html`의 `EXEC_URL`은 그대로 둬도 됩니다.) -- `site/index.html`을 수정한 뒤에는 호스팅에 다시 올려야 반영됩니다 (GitHub Pages는 push, Netlify Drop은 다시 드래그). - -## 자동 생성되는 것들 - -첫 제출이 들어오면 자동으로: -- 스프레드시트에 `출석부` 시트와 헤더가 생성됩니다. -- 내 드라이브에 `출석_서명이미지` 폴더가 생성되고, 서명 PNG 파일들이 여기에 저장됩니다 (시트 셀에는 이 이미지가 바로 삽입되어 보입니다). diff --git a/site/index.html b/site/index.html deleted file mode 100644 index 656f4e1..0000000 --- a/site/index.html +++ /dev/null @@ -1,234 +0,0 @@ - - -
- - -
-