diff --git a/.github/workflows/deploy-gongju-moon-observation-cloudflare.yml b/.github/workflows/deploy-gongju-moon-observation-cloudflare.yml new file mode 100644 index 0000000..5fb846b --- /dev/null +++ b/.github/workflows/deploy-gongju-moon-observation-cloudflare.yml @@ -0,0 +1,129 @@ +name: Deploy Gongju Moon Observation to Cloudflare + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: gongju-moon-observation-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + defaults: + run: + working-directory: gongju-moon-observation + env: + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_CUSTOM_DOMAIN: ${{ vars.CLOUDFLARE_CUSTOM_DOMAIN }} + SESSION_SECRET: ${{ secrets.SESSION_SECRET }} + TOKEN_ENCRYPTION_KEY: ${{ secrets.TOKEN_ENCRYPTION_KEY }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: gongju-moon-observation/package-lock.json + - run: npm ci + - run: npm run lint + - name: Validate production configuration + shell: bash + run: | + set -euo pipefail + required=( + CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN + CLOUDFLARE_CUSTOM_DOMAIN + SESSION_SECRET + TOKEN_ENCRYPTION_KEY + GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + ) + for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "필수 production secret/variable ${name}가 없습니다." >&2 + exit 78 + fi + done + if [[ ${#SESSION_SECRET} -lt 32 || ${#TOKEN_ENCRYPTION_KEY} -lt 32 ]]; then + echo "SESSION_SECRET과 TOKEN_ENCRYPTION_KEY는 각각 32자 이상이어야 합니다." >&2 + exit 78 + fi + node scripts/configure-cloudflare-deploy.mjs + - name: Build and verify direct Cloudflare target + env: + CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH: wrangler.cloudflare.deploy.json + NEXT_PUBLIC_SITE_ORIGIN: https://${{ vars.CLOUDFLARE_CUSTOM_DOMAIN }} + run: | + npm run build + node --test tests/cloudflare-deploy.test.mjs + - name: Create ephemeral Wrangler secrets file + shell: bash + run: | + node --input-type=module <<'NODE' + import { writeFile } from "node:fs/promises"; + const values = { + SESSION_SECRET: process.env.SESSION_SECRET, + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + PUBLIC_ORIGIN: `https://${process.env.CLOUDFLARE_CUSTOM_DOMAIN}`, + }; + await writeFile(process.env.RUNNER_TEMP + "/gongju-worker-secrets.json", JSON.stringify(values)); + NODE + - name: Deploy Worker and provision D1 + shell: bash + run: | + npx wrangler deploy \ + --config dist/server/wrangler.json \ + --secrets-file "$RUNNER_TEMP/gongju-worker-secrets.json" \ + --message "${GITHUB_SHA} central drive.file release" + - name: Apply D1 migrations + shell: bash + run: | + npx wrangler d1 migrations apply DB \ + --remote \ + --config dist/server/wrangler.json + - name: Verify production health + shell: bash + run: | + set -euo pipefail + endpoint="https://${CLOUDFLARE_CUSTOM_DOMAIN}/api/health" + for attempt in {1..12}; do + if payload="$(curl --fail --silent --show-error --max-time 15 "$endpoint")"; then + HEALTH_PAYLOAD="$payload" node --input-type=module <<'NODE' + const health = JSON.parse(process.env.HEALTH_PAYLOAD || "{}"); + if (health.ok !== true || health.database !== true || health.googleOAuth !== true) { + throw new Error(`운영 상태가 준비되지 않았습니다: ${JSON.stringify(health)}`); + } + NODE + echo "운영 상태 확인 완료: $endpoint" + break + fi + if [[ $attempt -eq 12 ]]; then + echo "운영 상태 확인에 실패했습니다: $endpoint" >&2 + exit 1 + fi + sleep 5 + done + - name: Remove ephemeral secrets file + if: always() + run: rm -f "$RUNNER_TEMP/gongju-worker-secrets.json" + - name: Publish deployment summary + run: | + { + echo "## 공주 달 관찰 탐험대 배포 완료" + echo + echo "- 운영 주소: https://${CLOUDFLARE_CUSTOM_DOMAIN}" + echo "- OAuth callback: https://${CLOUDFLARE_CUSTOM_DOMAIN}/api/oauth/google/callback" + echo "- Health: https://${CLOUDFLARE_CUSTOM_DOMAIN}/api/health" + echo "- Commit: ${GITHUB_SHA}" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/gongju-moon-observation-ci.yml b/.github/workflows/gongju-moon-observation-ci.yml new file mode 100644 index 0000000..8239f79 --- /dev/null +++ b/.github/workflows/gongju-moon-observation-ci.yml @@ -0,0 +1,50 @@ +name: Gongju Moon Observation CI + +on: + pull_request: + paths: + - "gongju-moon-observation/**" + - ".github/workflows/gongju-moon-observation-ci.yml" + push: + branches: [main] + paths: + - "gongju-moon-observation/**" + - ".github/workflows/gongju-moon-observation-ci.yml" + +permissions: + contents: read + +concurrency: + group: gongju-moon-observation-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + defaults: + run: + working-directory: gongju-moon-observation + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: gongju-moon-observation/package-lock.json + - run: npm ci + - run: npm run lint + - name: Verify existing application build and behavior + run: npm test + - name: Build direct Cloudflare target + env: + CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH: wrangler.cloudflare.jsonc + run: npm run build + - name: Verify direct Cloudflare deployment contract + run: node --test tests/cloudflare-deploy.test.mjs + - name: Preserve verified Worker build output + uses: actions/upload-artifact@v4 + with: + name: gongju-moon-observation-build + path: gongju-moon-observation/dist + if-no-files-found: error + retention-days: 7 diff --git a/gongju-moon-observation/.gitignore b/gongju-moon-observation/.gitignore index c413892..22ae56e 100644 --- a/gongju-moon-observation/.gitignore +++ b/gongju-moon-observation/.gitignore @@ -40,3 +40,4 @@ next-env.d.ts /.sites-runtime/ /outputs/ /work/ +/wrangler.cloudflare.deploy.json diff --git a/gongju-moon-observation/README.md b/gongju-moon-observation/README.md index b7c7de5..15d14a5 100644 --- a/gongju-moon-observation/README.md +++ b/gongju-moon-observation/README.md @@ -1,42 +1,215 @@ -# 공주 달 관찰 탐험대 +# 공주 달 관찰 탐험대 — 중앙 Google Drive OAuth형 -공주시 초등학교 4학년 과학 수업용 달 관찰 웹앱입니다. 학생은 계정을 만들지 않고 교사가 제공한 긴 수업 참여 링크(QR)로 입장하며, 달 모양과 관찰 가능 시간을 확인하고 사진·관찰 기록을 제출할 수 있습니다. +공주시 초등 과학 수업용 달 관찰 웹앱입니다. 여러 비개발자 교사가 하나의 중앙 서비스에 접속해 자신의 Google Drive를 연결하고, 학생은 교사가 제공한 QR로 로그인 없이 사진과 관찰 기록을 제출합니다. -## 구성 +> 운영 배포 대상은 **소유한 커스텀 도메인에 연결한 Cloudflare Worker**입니다. ChatGPT Sites는 13세 미만 아동을 대상으로 하는 사이트를 허용하지 않으므로 학생 운영 주소로 사용하지 않습니다. `.openai/hosting.json`은 기존 개발 이력과 로컬 호환을 위해 남아 있지만 새 운영 배포에는 사용하지 않습니다. -- `public/index.html`: 기존 디자인을 유지한 학생용 정적 화면 -- `app/join`: 수업 참여 토큰을 HttpOnly 세션 쿠키로 교환 -- `app/admin`: 교사용 QR·관찰 기록 관리 화면 -- `app/api`: 세션, 제출, 갤러리, 보호된 이미지, 교사용 관리 API -- D1 `DB`: 학생 번호·이름·관찰 시각·메모·상태 등 메타데이터 -- R2 `BUCKET`: 이름이 포함되지 않은 UUID 경로의 사진 파일 +## 핵심 구조 -## 개인정보·안전 설계 +```text +교사 브라우저 + └─ Google OAuth (openid + email + drive.file) + └─ 교사별 앱 전용 Drive 폴더 자동 생성 -- 학생 계정 및 이메일 로그인 없음 -- 제출·갤러리·사진 조회는 유효한 수업 세션 필요 -- 학생 갤러리에는 마스킹 이름만 반환 -- 사진은 비공개 R2에서 Worker를 통해서만 전달 -- 업로드 MIME·매직바이트·크기·번호·이름·시각·메모를 서버에서 재검증 -- 서버 저장 전 JPEG·PNG·WebP의 EXIF·위치·텍스트 메타데이터 제거 -- 기기 세션당 10분 3회, 학급당 1시간 100회 제출 제한 -- 요청 UUID 고유 제약으로 중복 저장 방지 -- 검색엔진 차단, no-store, no-referrer, nosniff 보안 헤더 적용 +학생 브라우저 + └─ 교사별 QR → 60일 수업 세션 + └─ 중앙 Cloudflare Worker가 사진 검증·메타데이터 제거 + └─ 해당 교사의 Google Drive에 직접 저장 + +중앙 Cloudflare D1 + └─ 교사 연결·학급 라우팅·비식별 제출 영수증만 저장 +``` + +## 중앙 저장 원칙 + +### 중앙 D1에 저장하는 정보 + +- 교사 Google 계정 식별자, 이메일, 표시 이름 +- AES-GCM으로 암호화한 Google refresh token +- 교사별 Drive 폴더 ID +- 학급명, 갤러리 설정, 암호화된 수업 초대 토큰 +- 중복 제출·요청 제한에 필요한 비식별 영수증 + - 요청 UUID + - 학급 UUID + - 무작위 학생 세션 UUID + - Google Drive 파일 ID + - 공개/숨김 상태 + - 생성 시각 + +### 중앙 D1/R2에 새로 저장하지 않는 정보 + +- 학생 이름 +- 학생 번호 +- 학생 관찰 메모 +- 학생 관찰 시각 +- 학생 사진 또는 썸네일 + +학생 정보는 교사의 Google Drive 사진 파일 `description`에 JSON 메타데이터로 저장됩니다. 사진 파일 이름에는 학생 이름이나 번호를 넣지 않습니다. + +기존 ChatGPT Sites D1/R2 버전의 `observations` 테이블과 과거 파일은 배포 과정에서 임의 삭제하지 않습니다. 새 API는 해당 테이블과 R2에 더 이상 쓰거나 읽지 않습니다. 새 Cloudflare 계정에 배포할 때는 `drizzle-cloudflare/0000_central_drive_oauth.sql`만 적용하므로 학생 개인정보 컬럼이 있는 레거시 테이블 자체가 생성되지 않습니다. + +## Google 권한 + +Drive 관련 권한은 다음 하나만 요청합니다. + +```text +https://www.googleapis.com/auth/drive.file +``` + +교사 계정 식별을 위해 표준 OpenID Connect 범위 `openid email`을 함께 요청합니다. Drive 전체 읽기·쓰기 권한인 `drive`, `drive.readonly` 등은 요청하지 않습니다. `drive.file` 범위에서는 이 앱이 생성한 폴더와 파일만 관리합니다. + +## 교사 UX + +1. `/admin`에서 **Google Drive 연결하기**를 누릅니다. +2. Google 계정을 선택하고 `drive.file` 권한을 승인합니다. +3. 앱이 `공주 달 관찰 탐험대/4학년 1반` 폴더를 만듭니다. +4. 관리 화면에 표시된 QR을 학생에게 보여 줍니다. +5. 제출 사진은 교사 Drive에 저장되고 관리 화면에서 숨김·삭제할 수 있습니다. +6. 연결 해제 시 중앙 계정·토큰·영수증은 삭제되지만 교사 Drive 파일은 그대로 남습니다. + +## 학생 UX + +1. 교사의 QR을 촬영합니다. +2. 계정 로그인 없이 60일 수업 세션을 받습니다. +3. 번호·이름·관찰 시각·메모와 사진을 제출합니다. +4. 교사가 갤러리를 켠 경우 마스킹된 이름으로 같은 학급의 사진을 봅니다. + +## 운영 배포 구조 + +직접 Cloudflare 배포 설정은 `wrangler.cloudflare.jsonc`에 있습니다. + +```text +Cloudflare Worker +├─ Vinext/Next.js 서버 +├─ Static Assets +├─ D1 binding: DB +└─ Custom Domain +``` + +R2 binding은 사용하지 않습니다. Wrangler의 자동 리소스 프로비저닝을 사용하므로 첫 배포 시 D1이 자동 생성됩니다. 운영 D1 마이그레이션 디렉터리는 `drizzle-cloudflare`입니다. + +### GitHub production 환경 값 + +GitHub 저장소의 **Settings → Environments → production**에 다음 Secret을 등록합니다. + +| 이름 | 용도 | +|---|---| +| `CLOUDFLARE_ACCOUNT_ID` | 배포할 Cloudflare 계정 ID | +| `CLOUDFLARE_API_TOKEN` | Worker·D1·Custom Domain 배포 권한 토큰 | +| `SESSION_SECRET` | 학생·교사·OAuth state 쿠키 서명. 32자 이상 | +| `TOKEN_ENCRYPTION_KEY` | refresh token AES-GCM 암호화. 32자 이상 | +| `GOOGLE_CLIENT_ID` | Google OAuth 웹 애플리케이션 Client ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | + +같은 `production` 환경에 다음 Variable을 등록합니다. + +| 이름 | 예시 | 용도 | +|---|---|---| +| `CLOUDFLARE_CUSTOM_DOMAIN` | `moon.example.com` | 프로토콜과 경로를 제외한 운영 호스트명 | -## 환경 값 +`PUBLIC_ORIGIN`은 배포 workflow가 `https://CLOUDFLARE_CUSTOM_DOMAIN`으로 생성해 Worker Secret으로 주입합니다. 실제 Secret 값은 소스·로그·Wrangler 설정에 기록하지 않습니다. -배포 환경에는 다음 값을 설정해야 합니다. 실제 값은 소스에 커밋하지 않습니다. +### Cloudflare API Token 권한 -- `SESSION_SECRET`: 세션 서명용 32바이트 이상의 무작위 값 -- `CLASS_INVITE_TOKEN`: 학생 참여 링크에 사용하는 128비트 이상의 무작위 값 -- `ADMIN_PASSWORD_HASH`: 교사용 비밀번호의 SHA-256 해시 -- `CLASS_ID`: 내부 학급 식별자(기본값 `gongju-4-1`) -- `CLASS_LABEL`: 화면 표시용 학급명(기본값 `4학년 1반`) +최소한 다음 작업을 수행할 수 있어야 합니다. + +- Workers Scripts 편집 +- D1 편집 +- Workers Routes 또는 Custom Domains 편집 +- 대상 Zone 읽기 + +커스텀 도메인은 같은 Cloudflare 계정에 등록된 Zone의 호스트명이어야 합니다. + +## Google Cloud Console 설정 + +1. 소유한 운영 도메인을 준비하고 HTTPS가 적용될 Cloudflare Zone에 등록합니다. +2. Google Cloud 프로젝트에서 Google Drive API를 사용 설정합니다. +3. OAuth 동의 화면에 앱 이름, 지원 이메일, 홈페이지, 개인정보 처리방침과 이용 안내를 설정합니다. +4. 범위는 `openid`, `email`, `drive.file`만 등록합니다. +5. **웹 애플리케이션** OAuth Client를 만듭니다. +6. 승인된 JavaScript origin에 운영 origin을 등록합니다. +7. 승인된 리디렉션 URI를 정확히 등록합니다. + +```text +https://moon.example.com/api/oauth/google/callback +``` + +운영 문서 URL 예시는 다음과 같습니다. + +```text +홈페이지: https://moon.example.com/ +개인정보 처리 안내: https://moon.example.com/privacy +이용 안내: https://moon.example.com/terms +``` + +공개 OAuth 앱의 홈페이지와 정책 문서는 운영자가 소유하고 검증할 수 있는 도메인에 두는 것을 전제로 합니다. `workers.dev`, `chatgpt.site` 같은 제공자 공용 도메인을 공개 서비스의 최종 OAuth 도메인으로 사용하지 않습니다. + +## 배포 절차 + +코드 검증 workflow: + +```text +.github/workflows/gongju-moon-observation-ci.yml +``` + +운영 배포 workflow: + +```text +.github/workflows/deploy-gongju-moon-observation-cloudflare.yml +``` + +운영 Secret·Variable과 Google OAuth 설정을 완료한 뒤 GitHub의 **Actions → Deploy Gongju Moon Observation to Cloudflare → Run workflow**를 실행합니다. Workflow는 다음을 자동 수행합니다. + +1. 의존성 설치와 lint +2. 커스텀 도메인용 Wrangler 설정 생성 +3. Vinext/Cloudflare Worker 빌드 +4. D1 자동 생성과 Worker 배포 +5. `drizzle-cloudflare` 원격 마이그레이션 적용 +6. Google OAuth·D1 health check +7. 운영 URL 요약 출력 + +## 기존 Sites D1을 전환하는 경우 + +기존 Sites D1에 새 테이블을 추가하는 migration은 `drizzle/0001_central_drive_oauth.sql`입니다. + +```text +teachers +classes +submission_receipts +``` + +그러나 초등학생 대상 운영 사이트는 ChatGPT Sites에 새 버전으로 배포하지 않습니다. 이 migration은 기존 데이터 보존·코드 이력과 별도 이전 작업을 위해서만 유지합니다. ## 개발 명령 -- `npm run build:static`: Tailwind Play CDN 없이 학생 화면용 `public/app.css` 생성 -- `npm run db:generate`: D1 마이그레이션 생성 -- `npm run lint`: 정적 검사 -- `npm run build`: 정적 CSS와 Cloudflare Worker/Vinext 빌드 -- `npm test`: 빌드 후 공개 화면·인증 차단·Blob 업로드 회귀 검사 +```bash +npm run install:ci +npm run lint +npm test +``` + +직접 Cloudflare 대상 빌드: + +```bash +CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH=wrangler.cloudflare.jsonc npm run build +node --test tests/cloudflare-deploy.test.mjs +``` + +- `npm run build:static`: 학생 화면용 정적 CSS 생성 +- `npm run build`: Vinext/Cloudflare Worker 빌드 +- `npm test`: 기존 화면·OAuth 범위·Drive 저장·D1 비식별화 회귀 검사 +- `tests/cloudflare-deploy.test.mjs`: R2 미사용·D1 clean schema·직접 배포 산출물 검사 + +## 보안 설계 + +- OAuth state: 서명된 HttpOnly·Secure·SameSite=Lax 쿠키 +- 교사·학생 세션: HMAC 서명 HttpOnly 쿠키 +- refresh token: AES-GCM 암호화 후 D1 저장 +- 사진: 브라우저 압축 후 서버에서 MIME·매직바이트·용량 재검증 +- JPEG/PNG/WebP의 EXIF·위치·텍스트 메타데이터 제거 +- 학생 갤러리 이름 마스킹 +- 요청 UUID 고유 제약으로 중복 저장 방지 +- 기기 세션당 10분 3회, 학급당 1시간 100회 제출 제한 +- Google Drive 업로드 성공 후 D1 영수증 실패 시 Drive 파일 보상 삭제 +- 교사 연결 해제 시 중앙 계정·토큰·영수증 삭제 및 Google 토큰 폐기 +- `no-store`, `no-referrer`, `nosniff`, `noindex` 보안 헤더 diff --git a/gongju-moon-observation/app/admin/page.tsx b/gongju-moon-observation/app/admin/page.tsx index f4d0c0c..8ceddc5 100644 --- a/gongju-moon-observation/app/admin/page.tsx +++ b/gongju-moon-observation/app/admin/page.tsx @@ -1,8 +1,18 @@ "use client"; -import { FormEvent, useCallback, useEffect, useState } from "react"; -import { QRCodeSVG } from "qrcode.react"; +import { useCallback, useEffect, useState } from "react"; +import type { ChangeEvent, FormEvent } from "react"; import Link from "next/link"; +import { QRCodeSVG } from "qrcode.react"; + +interface ProfileResult { + authenticated: boolean; + googleOAuthConfigured: boolean; + teacher?: { email: string; displayName: string; rootFolderUrl: string }; + classRoom?: { id: string; label: string; galleryEnabled: boolean; driveFolderUrl: string }; + reconnectUrl?: string; + message?: string; +} interface Observation { id: string; @@ -14,9 +24,10 @@ interface Observation { status: "visible" | "hidden"; createdAt: string; imageUrl: string; + driveUrl: string | null; } -interface PageResult { +interface ObservationPage { items: Observation[]; hasMore: boolean; nextCursor: string | null; @@ -24,74 +35,102 @@ interface PageResult { } export default function AdminPage() { - const [authenticated, setAuthenticated] = useState(null); - const [password, setPassword] = useState(""); - const [message, setMessage] = useState(""); + const [profile, setProfile] = useState(null); const [items, setItems] = useState([]); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(false); const [joinUrl, setJoinUrl] = useState(""); - const [classLabel, setClassLabel] = useState("4학년 1반"); + const [label, setLabel] = useState("4학년 1반"); + const [galleryEnabled, setGalleryEnabled] = useState(true); + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + const loadRecords = useCallback(async (nextCursor: string | null = null, append = false) => { + const query = new URLSearchParams({ limit: "30" }); + if (nextCursor) query.set("cursor", nextCursor); + const response = await fetch(`/api/admin/observations?${query}`, { credentials: "same-origin" }); + const result = (await response.json().catch(() => ({}))) as ObservationPage; + if (!response.ok) throw new Error(result.message || "관찰 기록을 불러오지 못했습니다."); + setItems((current) => append ? [...current, ...result.items] : result.items); + setCursor(result.nextCursor); + setHasMore(result.hasMore); + }, []); + + const loadInvite = useCallback(async () => { + const response = await fetch("/api/admin/invite", { credentials: "same-origin" }); + const result = (await response.json().catch(() => ({}))) as { joinUrl?: string; message?: string }; + if (!response.ok) throw new Error(result.message || "수업 참여 링크를 불러오지 못했습니다."); + setJoinUrl(result.joinUrl || ""); + }, []); - const loadData = useCallback(async (nextCursor: string | null = null, append = false) => { + const loadProfile = useCallback(async () => { setLoading(true); + setErrorMessage(""); try { - const query = nextCursor ? `?cursor=${encodeURIComponent(nextCursor)}` : ""; - const [recordsResponse, inviteResponse] = await Promise.all([ - fetch(`/api/admin/observations${query}`, { credentials: "same-origin" }), - fetch("/api/admin/invite", { credentials: "same-origin" }), - ]); - if (recordsResponse.status === 401 || inviteResponse.status === 401) { - setAuthenticated(false); - return; + const response = await fetch("/api/admin/profile", { credentials: "same-origin" }); + const result = (await response.json().catch(() => ({}))) as ProfileResult; + if (!response.ok) throw new Error(result.message || "교사 정보를 불러오지 못했습니다."); + setProfile(result); + if (result.authenticated && result.classRoom) { + setLabel(result.classRoom.label); + setGalleryEnabled(result.classRoom.galleryEnabled); + await Promise.all([loadInvite(), loadRecords()]); } - const records = (await recordsResponse.json()) as PageResult; - const invite = (await inviteResponse.json()) as { joinUrl?: string; classLabel?: string; message?: string }; - if (!recordsResponse.ok) throw new Error(records.message || "관찰 기록을 불러오지 못했습니다."); - if (!inviteResponse.ok) throw new Error(invite.message || "수업 링크를 불러오지 못했습니다."); - setItems((current) => append ? [...current, ...records.items] : records.items); - setCursor(records.nextCursor); - setHasMore(records.hasMore); - setJoinUrl(invite.joinUrl || ""); - setClassLabel(invite.classLabel || "4학년 1반"); - setAuthenticated(true); } catch (error) { - setMessage(error instanceof Error ? error.message : "자료를 불러오지 못했습니다."); + setErrorMessage(error instanceof Error ? error.message : "교사 정보를 불러오지 못했습니다."); } finally { setLoading(false); } - }, []); + }, [loadInvite, loadRecords]); useEffect(() => { - fetch("/api/admin/session", { credentials: "same-origin" }) - .then((response) => response.json()) - .then((result: { authenticated?: boolean }) => { - const signedIn = Boolean(result.authenticated); - setAuthenticated(signedIn); - if (signedIn) void loadData(); - }) - .catch(() => setAuthenticated(false)); - }, [loadData]); - - async function signIn(event: FormEvent) { + const params = new URLSearchParams(window.location.search); + const queryError = params.get("error"); + const connected = params.get("connected") === "1"; + if (params.size) window.history.replaceState({}, "", "/admin"); + void loadProfile().finally(() => { + if (queryError) setErrorMessage(queryError); + if (connected) setMessage("Google Drive 연결을 완료했습니다."); + }); + }, [loadProfile]); + + async function saveSettings(event: FormEvent) { event.preventDefault(); setLoading(true); setMessage(""); + setErrorMessage(""); try { - const response = await fetch("/api/admin/session", { - method: "POST", + const response = await fetch("/api/admin/profile", { + method: "PATCH", credentials: "same-origin", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), + body: JSON.stringify({ label, galleryEnabled }), }); const result = (await response.json().catch(() => ({}))) as { message?: string }; - if (!response.ok) throw new Error(result.message || "로그인하지 못했습니다."); - setPassword(""); - setAuthenticated(true); - await loadData(); + if (!response.ok) throw new Error(result.message || "학급 설정을 저장하지 못했습니다."); + setMessage(result.message || "학급 설정을 저장했습니다."); + await loadProfile(); } catch (error) { - setMessage(error instanceof Error ? error.message : "로그인하지 못했습니다."); + setErrorMessage(error instanceof Error ? error.message : "학급 설정을 저장하지 못했습니다."); + } finally { + setLoading(false); + } + } + + async function rotateInvite() { + if (!window.confirm("새 수업 참여 링크를 만들까요? 기존 링크로는 새 기기가 입장할 수 없습니다.")) return; + setLoading(true); + setMessage(""); + setErrorMessage(""); + try { + const response = await fetch("/api/admin/invite", { method: "POST", credentials: "same-origin" }); + const result = (await response.json().catch(() => ({}))) as { joinUrl?: string; message?: string }; + if (!response.ok) throw new Error(result.message || "새 수업 링크를 만들지 못했습니다."); + setJoinUrl(result.joinUrl || ""); + setMessage(result.message || "새 수업 링크를 만들었습니다."); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "새 수업 링크를 만들지 못했습니다."); } finally { setLoading(false); } @@ -99,6 +138,7 @@ export default function AdminPage() { async function updateStatus(item: Observation) { const status = item.status === "visible" ? "hidden" : "visible"; + setErrorMessage(""); const response = await fetch(`/api/admin/observations/${item.id}`, { method: "PATCH", credentials: "same-origin", @@ -107,137 +147,182 @@ export default function AdminPage() { }); const result = (await response.json().catch(() => ({}))) as { message?: string }; if (!response.ok) { - setMessage(result.message || "공개 상태를 바꾸지 못했습니다."); + setErrorMessage(result.message || "공개 상태를 바꾸지 못했습니다."); return; } setItems((current) => current.map((entry) => entry.id === item.id ? { ...entry, status } : entry)); + setMessage(result.message || "공개 상태를 변경했습니다."); } async function remove(item: Observation) { - if (!window.confirm(`${item.studentNumber}번 ${item.studentName} 학생의 기록을 완전히 삭제할까요?`)) return; + if (!window.confirm(`${item.studentNumber}번 ${item.studentName} 학생의 사진과 기록을 Google Drive에서 완전히 삭제할까요?`)) return; + setErrorMessage(""); const response = await fetch(`/api/admin/observations/${item.id}`, { method: "DELETE", credentials: "same-origin", }); const result = (await response.json().catch(() => ({}))) as { message?: string }; if (!response.ok) { - setMessage(result.message || "기록을 삭제하지 못했습니다."); + setErrorMessage(result.message || "관찰 기록을 삭제하지 못했습니다."); return; } setItems((current) => current.filter((entry) => entry.id !== item.id)); + setMessage(result.message || "관찰 기록을 삭제했습니다."); + } + + async function disconnect() { + if (!window.confirm("중앙 서비스 연결을 해제할까요? 선생님의 Google Drive 폴더와 사진은 그대로 남습니다.")) return; + setLoading(true); + setMessage(""); + setErrorMessage(""); + try { + const response = await fetch("/api/admin/disconnect", { method: "DELETE", credentials: "same-origin" }); + const result = (await response.json().catch(() => ({}))) as { message?: string }; + if (!response.ok) throw new Error(result.message || "연결을 해제하지 못했습니다."); + setProfile({ authenticated: false, googleOAuthConfigured: true }); + setItems([]); + setJoinUrl(""); + setMessage(result.message || "중앙 서비스 연결을 해제했습니다."); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "연결을 해제하지 못했습니다."); + } finally { + setLoading(false); + } } async function signOut() { await fetch("/api/admin/session", { method: "DELETE", credentials: "same-origin" }); - setAuthenticated(false); + setProfile({ authenticated: false, googleOAuthConfigured: profile?.googleOAuthConfigured ?? true }); setItems([]); setJoinUrl(""); } - if (authenticated === null) { - return
관리 화면을 준비하고 있어요…
; + if (profile === null && loading) { + return
교사 관리 화면을 준비하고 있어요…
; } - if (!authenticated) { + if (!profile?.authenticated) { + const configured = profile?.googleOAuthConfigured !== false; return ( -
-
-

교사용 관리

-

관찰 기록 관리

-

학생 전체 이름과 사진은 교사 로그인 후에만 표시됩니다.

- - {message ?

{message}

: null} - +
+
+

교사용 중앙 서비스

+

내 Google Drive에 제출받기

+

교사가 Google 계정을 한 번 연결하면 전용 폴더와 학생 QR이 자동으로 만들어집니다. 학생은 Google 로그인 없이 사진을 제출합니다.

+
+ 이 앱은 drive.file 권한만 요청하며, 앱이 만든 폴더와 파일만 관리합니다. 학생 사진·이름·메모는 중앙 D1이 아니라 선생님의 Google Drive 파일에 저장됩니다. +
+ {message ?

{message}

: null} + {errorMessage ?

{errorMessage}

: null} + {configured ? ( + Google Drive 연결하기 + ) : ( +

운영자가 Google OAuth Client ID와 Secret을 아직 설정하지 않았습니다.

+ )} 학생 화면으로 돌아가기 - +
개인정보 처리 안내이용 안내
+
); } + const teacher = profile.teacher!; + const classRoom = profile.classRoom!; return (
-

교사용 관리 · {classLabel}

+

교사용 관리 · {classRoom.label}

달 관찰 기록

+

{teacher.displayName} · {teacher.email}

-
+
학생 화면 + Drive 다시 연결 +
- {joinUrl ? ( -
-
- -
-
-

학생 수업 참여 QR

-

학생에게 이 QR만 안내하세요

-

QR을 촬영하면 계정 로그인 없이 이 기기에서 60일 동안 제출과 갤러리를 이용할 수 있습니다.

-
- - 링크 확인 -
+ {message ?

{message}

: null} + {errorMessage ?

{errorMessage}

: null} + +
+
+

학급 설정

+

제출함 이름과 공개 범위

+ + + +
+ +
+

학생 수업 참여

+

QR을 학생에게 보여 주세요

+ {joinUrl ? ( + <> +
+

{joinUrl}

+

한 번 입장한 학생 기기는 60일 동안 제출할 수 있습니다.

+ + + ) :

수업 링크를 불러오고 있어요…

}
- ) : null} +
-
+
-
-

전체 이름은 이 화면에서만 확인됩니다

-

제출된 기록 {items.length}건

-
- +

선생님의 Google Drive에서 불러옴

제출 기록

+
- {message ?

{message}

: null} - {items.length === 0 && !loading ?

아직 제출된 기록이 없습니다.

: null} -
- {items.map((item) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${item.studentNumber}번 -
-
-

{item.studentNumber}번 {item.studentName}

- - {item.status === "visible" ? "공개" : "숨김"} - -
-

관찰 {item.observedAt.replace("T", " ")}

- {item.memo ?

{item.memo}

: null} -
- - + {items.length === 0 ? ( +
아직 제출된 사진이 없습니다.
+ ) : ( +
+ {items.map((item) => ( +
+ {`${item.studentNumber}번 +
+
+

{item.studentNumber}번 {item.studentName}

+ {item.status === "visible" ? "학생 공개" : "숨김"} +
+

관찰 {formatObservedAt(item.observedAt)} · {formatBytes(item.imageBytes)}

+ {item.memo ?

{item.memo}

: null} +
+ + {item.driveUrl ? Drive 열기 : null} + +
-
-
- ))} -
- {hasMore && cursor ? ( -
- + + ))}
- ) : null} + )} + {hasMore ?
: null}
+
개인정보 처리 안내이용 안내
); } + +function formatObservedAt(value: string) { + return value.replace("T", " ").slice(0, 16); +} + +function formatBytes(value: number) { + if (!value) return "용량 미확인"; + if (value < 1024 * 1024) return `${Math.max(1, Math.round(value / 1024))}KB`; + return `${(value / 1024 / 1024).toFixed(1)}MB`; +} diff --git a/gongju-moon-observation/app/api/admin/disconnect/route.ts b/gongju-moon-observation/app/api/admin/disconnect/route.ts new file mode 100644 index 0000000..edf42f9 --- /dev/null +++ b/gongju-moon-observation/app/api/admin/disconnect/route.ts @@ -0,0 +1,42 @@ +import { clearTeacherCookie, getTeacherSession } from "../../../../lib/auth"; +import { decryptString } from "../../../../lib/crypto"; +import { revokeGoogleToken } from "../../../../lib/google-drive"; +import { assertSameOrigin, errorResponse, HttpError, json } from "../../../../lib/http"; +import { getTokenEncryptionSecret } from "../../../../lib/runtime"; +import { deleteTeacherWorkspace, getTeacherWorkspace } from "../../../../lib/tenants"; + +export async function DELETE(request: Request) { + try { + assertSameOrigin(request); + const session = await getTeacherSession(request); + if (!session) throw new HttpError(401, "교사 로그인 후 이용해 주세요."); + const workspace = await getTeacherWorkspace(session.teacherId); + if (!workspace) throw new HttpError(404, "교사 연결 정보를 찾을 수 없습니다."); + + let refreshToken: string | null = null; + try { + refreshToken = await decryptString( + workspace.teacher.encryptedRefreshToken, + getTokenEncryptionSecret(), + ); + } catch { + // Central records must still be removable even if the old token cannot be decrypted. + } + + await deleteTeacherWorkspace(workspace.teacher.id, workspace.classRecord.id); + if (refreshToken) { + await revokeGoogleToken(refreshToken).catch((error) => { + console.warn("Google token revocation failed after local disconnect", error); + }); + } + return json( + { + ok: true, + message: "중앙 서비스 연결을 해제했습니다. 선생님의 Google Drive 폴더와 사진은 그대로 남아 있습니다.", + }, + { headers: { "Set-Cookie": clearTeacherCookie() } }, + ); + } catch (error) { + return errorResponse(error); + } +} diff --git a/gongju-moon-observation/app/api/admin/invite/route.ts b/gongju-moon-observation/app/api/admin/invite/route.ts index c9f7a71..710f0a9 100644 --- a/gongju-moon-observation/app/api/admin/invite/route.ts +++ b/gongju-moon-observation/app/api/admin/invite/route.ts @@ -1,14 +1,49 @@ -import { getAdminSession } from "../../../../lib/auth"; -import { errorResponse, HttpError, json } from "../../../../lib/http"; -import { getClassLabel, getEnv } from "../../../../lib/runtime"; +import { getTeacherSession } from "../../../../lib/auth"; +import { decryptString, encryptString, randomToken, sha256Hex } from "../../../../lib/crypto"; +import { assertSameOrigin, errorResponse, HttpError, json } from "../../../../lib/http"; +import { getPublicOrigin, getTokenEncryptionSecret } from "../../../../lib/runtime"; +import { getTeacherWorkspace, updateClass } from "../../../../lib/tenants"; export async function GET(request: Request) { try { - if (!(await getAdminSession(request))) throw new HttpError(401, "교사 로그인이 필요합니다."); - const runtime = getEnv(); - const url = new URL(request.url); - const joinUrl = `${url.origin}/join?t=${encodeURIComponent(runtime.CLASS_INVITE_TOKEN)}`; - return json({ joinUrl, classLabel: getClassLabel(runtime) }); + const session = await getTeacherSession(request); + if (!session) throw new HttpError(401, "교사 로그인 후 이용해 주세요."); + const workspace = await getTeacherWorkspace(session.teacherId); + if (!workspace) throw new HttpError(404, "교사 제출함 정보를 찾을 수 없습니다."); + const inviteToken = await decryptString( + workspace.classRecord.encryptedInviteToken, + getTokenEncryptionSecret(), + ); + return json({ + joinUrl: `${getPublicOrigin(request)}/join?t=${encodeURIComponent(inviteToken)}`, + classLabel: workspace.classRecord.label, + validFor: "새로 입장한 학생 기기에서 60일", + }); + } catch (error) { + return errorResponse(error); + } +} + +export async function POST(request: Request) { + try { + assertSameOrigin(request); + const session = await getTeacherSession(request); + if (!session) throw new HttpError(401, "교사 로그인 후 이용해 주세요."); + const workspace = await getTeacherWorkspace(session.teacherId); + if (!workspace) throw new HttpError(404, "교사 제출함 정보를 찾을 수 없습니다."); + const inviteToken = randomToken(32); + await updateClass({ + ...workspace.classRecord, + inviteTokenHash: await sha256Hex(inviteToken), + encryptedInviteToken: await encryptString(inviteToken, getTokenEncryptionSecret()), + updatedAt: new Date().toISOString(), + }); + return json({ + ok: true, + joinUrl: `${getPublicOrigin(request)}/join?t=${encodeURIComponent(inviteToken)}`, + classLabel: workspace.classRecord.label, + message: "새 수업 참여 링크를 만들었습니다. 기존 링크로는 새 기기가 입장할 수 없습니다.", + }); } catch (error) { return errorResponse(error); } diff --git a/gongju-moon-observation/app/api/admin/observations/[id]/route.ts b/gongju-moon-observation/app/api/admin/observations/[id]/route.ts index d49d53f..13a3e56 100644 --- a/gongju-moon-observation/app/api/admin/observations/[id]/route.ts +++ b/gongju-moon-observation/app/api/admin/observations/[id]/route.ts @@ -1,22 +1,22 @@ -import { getAdminSession } from "../../../../../lib/auth"; +import { getTeacherSession } from "../../../../../lib/auth"; +import { + assertObservationBelongsToClass, + deleteDriveFile, + getDriveFile, + getTeacherAccessToken, + googleDriveUserMessage, + parseObservationMetadata, + updateDriveFile, +} from "../../../../../lib/google-drive"; import { assertSameOrigin, errorResponse, HttpError, json } from "../../../../../lib/http"; -import { getClassId, getEnv } from "../../../../../lib/runtime"; +import { + deleteReceiptByDriveFileId, + getReceipt, + getTeacherWorkspace, + updateReceiptStatus, +} from "../../../../../lib/tenants"; -interface ManagedRow { - image_key: string; - status: string; -} - -async function getManagedObservation(id: string) { - const runtime = getEnv(); - const row = await runtime.DB.prepare( - "SELECT image_key, status FROM observations WHERE id = ? AND class_id = ? LIMIT 1", - ) - .bind(id, getClassId(runtime)) - .first(); - if (!row) throw new HttpError(404, "관찰 기록을 찾을 수 없습니다."); - return { runtime, row }; -} +const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export async function PATCH( request: Request, @@ -24,21 +24,53 @@ export async function PATCH( ) { try { assertSameOrigin(request); - if (!(await getAdminSession(request))) throw new HttpError(401, "교사 로그인이 필요합니다."); - const { id } = await context.params; + const session = await getTeacherSession(request); + if (!session) throw new HttpError(401, "교사 로그인 후 이용해 주세요."); + const { id: requestId } = await context.params; + if (!REQUEST_ID.test(requestId)) throw new HttpError(404, "관찰 기록을 찾을 수 없습니다."); const payload = (await request.json()) as { status?: unknown }; if (payload.status !== "visible" && payload.status !== "hidden") { - throw new HttpError(400, "공개 상태 값이 올바르지 않습니다."); + throw new HttpError(400, "공개 상태가 올바르지 않습니다."); + } + + const [workspace, receipt] = await Promise.all([ + getTeacherWorkspace(session.teacherId), + getReceipt(requestId), + ]); + if (!workspace || !receipt || receipt.classId !== workspace.classRecord.id) { + throw new HttpError(404, "관찰 기록을 찾을 수 없습니다."); } - const { runtime } = await getManagedObservation(id); - await runtime.DB.prepare( - "UPDATE observations SET status = ? WHERE id = ? AND class_id = ?", - ) - .bind(payload.status, id, getClassId(runtime)) - .run(); - return json({ ok: true, status: payload.status }); + const accessToken = await getTeacherAccessToken(request, workspace.teacher); + const file = await getDriveFile(accessToken, receipt.driveFileId); + assertObservationBelongsToClass(file, workspace.classRecord.driveFolderId, workspace.classRecord.id); + const metadata = parseObservationMetadata(file); + if (!metadata || metadata.requestId !== requestId) { + throw new HttpError(404, "관찰 기록 정보를 읽을 수 없습니다."); + } + if (metadata.status === payload.status) return json({ ok: true, status: payload.status }); + + const nextMetadata = { ...metadata, status: payload.status }; + await updateDriveFile(accessToken, receipt.driveFileId, { + description: JSON.stringify(nextMetadata), + appProperties: { ...file.appProperties, status: payload.status }, + }); + try { + await updateReceiptStatus(workspace.classRecord.id, receipt.driveFileId, payload.status); + } catch (error) { + await updateDriveFile(accessToken, receipt.driveFileId, { + description: JSON.stringify(metadata), + appProperties: { ...file.appProperties, status: metadata.status }, + }).catch((rollbackError) => console.warn("Drive status rollback failed", rollbackError)); + throw error; + } + return json({ + ok: true, + status: payload.status, + message: payload.status === "visible" ? "학생 갤러리에 다시 공개했습니다." : "학생 갤러리에서 숨겼습니다.", + }); } catch (error) { - return errorResponse(error); + const driveMessage = googleDriveUserMessage(error); + return driveMessage ? json({ message: driveMessage }, { status: 503 }) : errorResponse(error); } } @@ -48,21 +80,27 @@ export async function DELETE( ) { try { assertSameOrigin(request); - if (!(await getAdminSession(request))) throw new HttpError(401, "교사 로그인이 필요합니다."); - const { id } = await context.params; - const { runtime, row } = await getManagedObservation(id); - - await runtime.DB.prepare( - "UPDATE observations SET status = 'hidden' WHERE id = ? AND class_id = ?", - ) - .bind(id, getClassId(runtime)) - .run(); - await runtime.BUCKET.delete(row.image_key); - await runtime.DB.prepare("DELETE FROM observations WHERE id = ? AND class_id = ?") - .bind(id, getClassId(runtime)) - .run(); - return json({ ok: true }); + const session = await getTeacherSession(request); + if (!session) throw new HttpError(401, "교사 로그인 후 이용해 주세요."); + const { id: requestId } = await context.params; + if (!REQUEST_ID.test(requestId)) throw new HttpError(404, "관찰 기록을 찾을 수 없습니다."); + const [workspace, receipt] = await Promise.all([ + getTeacherWorkspace(session.teacherId), + getReceipt(requestId), + ]); + if (!workspace || !receipt || receipt.classId !== workspace.classRecord.id) { + throw new HttpError(404, "관찰 기록을 찾을 수 없습니다."); + } + const accessToken = await getTeacherAccessToken(request, workspace.teacher); + const file = await getDriveFile(accessToken, receipt.driveFileId); + assertObservationBelongsToClass(file, workspace.classRecord.driveFolderId, workspace.classRecord.id); + await deleteDriveFile(accessToken, receipt.driveFileId); + await deleteReceiptByDriveFileId(workspace.classRecord.id, receipt.driveFileId).catch((cleanupError) => { + console.warn("Submission receipt cleanup failed", cleanupError); + }); + return json({ ok: true, message: "Google Drive에서 관찰 기록을 완전히 삭제했습니다." }); } catch (error) { - return errorResponse(error); + const driveMessage = googleDriveUserMessage(error); + return driveMessage ? json({ message: driveMessage }, { status: 503 }) : errorResponse(error); } } diff --git a/gongju-moon-observation/app/api/admin/observations/route.ts b/gongju-moon-observation/app/api/admin/observations/route.ts index 5ae921e..f36e04d 100644 --- a/gongju-moon-observation/app/api/admin/observations/route.ts +++ b/gongju-moon-observation/app/api/admin/observations/route.ts @@ -1,65 +1,64 @@ -import { getAdminSession } from "../../../../lib/auth"; +import { getTeacherSession } from "../../../../lib/auth"; +import { + getTeacherAccessToken, + googleDriveUserMessage, + listObservationFiles, + parseObservationMetadata, +} from "../../../../lib/google-drive"; import { errorResponse, HttpError, json } from "../../../../lib/http"; -import { decodeCursor, encodeCursor } from "../../../../lib/observations"; -import { getClassId, getEnv } from "../../../../lib/runtime"; - -interface AdminObservationRow { - id: string; - student_number: number; - student_name: string; - observed_at: string; - memo: string; - image_bytes: number; - status: string; - created_at: string; -} +import { getTeacherWorkspace } from "../../../../lib/tenants"; export async function GET(request: Request) { try { - if (!(await getAdminSession(request))) throw new HttpError(401, "교사 로그인이 필요합니다."); - const runtime = getEnv(); - const classId = getClassId(runtime); - const url = new URL(request.url); - const cursorValue = url.searchParams.get("cursor"); - const cursor = decodeCursor(cursorValue); - if (cursorValue && !cursor) throw new HttpError(400, "이어보기 정보가 올바르지 않습니다."); - const limit = 30; + const session = await getTeacherSession(request); + if (!session) throw new HttpError(401, "교사 로그인 후 이용해 주세요."); + const workspace = await getTeacherWorkspace(session.teacherId); + if (!workspace) throw new HttpError(404, "교사 제출함 정보를 찾을 수 없습니다."); - const statement = cursor - ? runtime.DB.prepare( - `SELECT id, student_number, student_name, observed_at, memo, image_bytes, status, created_at - FROM observations - WHERE class_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) - ORDER BY created_at DESC, id DESC LIMIT ?`, - ).bind(classId, cursor.createdAt, cursor.createdAt, cursor.id, limit + 1) - : runtime.DB.prepare( - `SELECT id, student_number, student_name, observed_at, memo, image_bytes, status, created_at - FROM observations - WHERE class_id = ? - ORDER BY created_at DESC, id DESC LIMIT ?`, - ).bind(classId, limit + 1); - - const result = await statement.all(); - const rows = result.results || []; - const hasMore = rows.length > limit; - const visibleRows = rows.slice(0, limit); - const last = visibleRows.at(-1); + const url = new URL(request.url); + const cursor = normalizePageToken(url.searchParams.get("cursor")); + const limit = Math.min(50, Math.max(1, Number(url.searchParams.get("limit")) || 30)); + const accessToken = await getTeacherAccessToken(request, workspace.teacher); + const page = await listObservationFiles({ + accessToken, + folderId: workspace.classRecord.driveFolderId, + classId: workspace.classRecord.id, + pageToken: cursor, + pageSize: limit, + visibleOnly: false, + }); + const items = (page.files || []).flatMap((file) => { + const metadata = parseObservationMetadata(file); + if (!metadata) return []; + return [{ + id: metadata.requestId, + studentNumber: metadata.studentNumber, + studentName: metadata.studentName, + observedAt: metadata.observedAt, + memo: metadata.memo, + imageBytes: Number(file.size || 0), + status: metadata.status, + createdAt: metadata.createdAt, + imageUrl: `/api/images/${encodeURIComponent(metadata.requestId)}`, + driveUrl: file.webViewLink || null, + }]; + }); return json({ - items: visibleRows.map((row) => ({ - id: row.id, - studentNumber: row.student_number, - studentName: row.student_name, - observedAt: row.observed_at, - memo: row.memo, - imageBytes: row.image_bytes, - status: row.status, - createdAt: row.created_at, - imageUrl: `/api/images/${row.id}`, - })), - hasMore, - nextCursor: hasMore && last ? encodeCursor(last.created_at, last.id) : null, + items, + hasMore: Boolean(page.nextPageToken), + nextCursor: page.nextPageToken || null, + classLabel: workspace.classRecord.label, }); } catch (error) { - return errorResponse(error); + const driveMessage = googleDriveUserMessage(error); + return driveMessage ? json({ message: driveMessage }, { status: 503 }) : errorResponse(error); + } +} + +function normalizePageToken(value: string | null) { + if (!value) return null; + if (value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) { + throw new HttpError(400, "목록 이어보기 정보가 올바르지 않습니다."); } + return value; } diff --git a/gongju-moon-observation/app/api/admin/profile/route.ts b/gongju-moon-observation/app/api/admin/profile/route.ts new file mode 100644 index 0000000..e2c96b2 --- /dev/null +++ b/gongju-moon-observation/app/api/admin/profile/route.ts @@ -0,0 +1,85 @@ +import { getTeacherSession } from "../../../../lib/auth"; +import { + getTeacherAccessToken, + googleDriveUserMessage, + updateDriveFile, +} from "../../../../lib/google-drive"; +import { assertSameOrigin, errorResponse, HttpError, json } from "../../../../lib/http"; +import { getPublicOrigin, isGoogleOAuthConfigured } from "../../../../lib/runtime"; +import { getTeacherWorkspace, updateClass } from "../../../../lib/tenants"; + +export async function GET(request: Request) { + try { + const session = await getTeacherSession(request); + if (!session) { + return json({ authenticated: false, googleOAuthConfigured: isGoogleOAuthConfigured() }); + } + const workspace = await getTeacherWorkspace(session.teacherId); + if (!workspace) throw new HttpError(404, "교사 제출함 정보를 찾을 수 없습니다."); + return json({ + authenticated: true, + googleOAuthConfigured: isGoogleOAuthConfigured(), + teacher: { + email: workspace.teacher.email, + displayName: workspace.teacher.displayName, + rootFolderUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(workspace.teacher.rootFolderId)}`, + }, + classRoom: { + id: workspace.classRecord.id, + label: workspace.classRecord.label, + galleryEnabled: workspace.classRecord.galleryEnabled, + driveFolderUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(workspace.classRecord.driveFolderId)}`, + }, + reconnectUrl: `${getPublicOrigin(request)}/api/oauth/google/start?returnTo=%2Fadmin`, + }); + } catch (error) { + return errorResponse(error); + } +} + +export async function PATCH(request: Request) { + try { + assertSameOrigin(request); + const session = await getTeacherSession(request); + if (!session) throw new HttpError(401, "교사 로그인 후 이용해 주세요."); + const workspace = await getTeacherWorkspace(session.teacherId); + if (!workspace) throw new HttpError(404, "교사 제출함 정보를 찾을 수 없습니다."); + + const payload = (await request.json()) as { label?: unknown; galleryEnabled?: unknown }; + const label = normalizeLabel(payload.label ?? workspace.classRecord.label); + const galleryEnabled = typeof payload.galleryEnabled === "boolean" + ? payload.galleryEnabled + : workspace.classRecord.galleryEnabled; + const accessToken = await getTeacherAccessToken(request, workspace.teacher); + if (label !== workspace.classRecord.label) { + await updateDriveFile(accessToken, workspace.classRecord.driveFolderId, { name: label }); + } + const classRecord = await updateClass({ + ...workspace.classRecord, + label, + galleryEnabled, + updatedAt: new Date().toISOString(), + }); + return json({ + ok: true, + classRoom: { + id: classRecord.id, + label: classRecord.label, + galleryEnabled: classRecord.galleryEnabled, + }, + message: "학급 설정을 저장했습니다.", + }); + } catch (error) { + const driveMessage = googleDriveUserMessage(error); + return driveMessage ? json({ message: driveMessage }, { status: 503 }) : errorResponse(error); + } +} + +function normalizeLabel(value: unknown) { + if (typeof value !== "string") throw new HttpError(400, "학급명을 입력해 주세요."); + const label = value.normalize("NFC").trim(); + if (!label || Array.from(label).length > 50 || /\p{Cc}/u.test(label)) { + throw new HttpError(400, "학급명은 1자 이상 50자 이하로 입력해 주세요."); + } + return label; +} diff --git a/gongju-moon-observation/app/api/admin/session/route.ts b/gongju-moon-observation/app/api/admin/session/route.ts index 475a176..d32a355 100644 --- a/gongju-moon-observation/app/api/admin/session/route.ts +++ b/gongju-moon-observation/app/api/admin/session/route.ts @@ -1,36 +1,19 @@ -import { - clearAdminCookie, - createAdminCookie, - getAdminSession, - safeSecretEqual, - sha256Hex, -} from "../../../../lib/auth"; -import { assertSameOrigin, errorResponse, HttpError, json } from "../../../../lib/http"; -import { getEnv } from "../../../../lib/runtime"; +import { clearTeacherCookie, getTeacherSession } from "../../../../lib/auth"; +import { assertSameOrigin, errorResponse, json } from "../../../../lib/http"; +import { isGoogleOAuthConfigured } from "../../../../lib/runtime"; +import { getTeacherWorkspace } from "../../../../lib/tenants"; export async function GET(request: Request) { try { - return json({ authenticated: Boolean(await getAdminSession(request)) }); - } catch (error) { - return errorResponse(error); - } -} - -export async function POST(request: Request) { - try { - assertSameOrigin(request); - const payload = (await request.json()) as { password?: unknown }; - if (typeof payload.password !== "string" || payload.password.length < 12 || payload.password.length > 256) { - throw new HttpError(401, "관리 비밀번호가 올바르지 않습니다."); - } - const submittedHash = await sha256Hex(payload.password); - if (!(await safeSecretEqual(submittedHash, getEnv().ADMIN_PASSWORD_HASH))) { - throw new HttpError(401, "관리 비밀번호가 올바르지 않습니다."); + const session = await getTeacherSession(request); + if (!session) { + return json({ authenticated: false, googleOAuthConfigured: isGoogleOAuthConfigured() }); } - return json( - { ok: true }, - { headers: { "Set-Cookie": await createAdminCookie() } }, - ); + const workspace = await getTeacherWorkspace(session.teacherId); + return json({ + authenticated: Boolean(workspace), + googleOAuthConfigured: isGoogleOAuthConfigured(), + }); } catch (error) { return errorResponse(error); } @@ -39,7 +22,7 @@ export async function POST(request: Request) { export async function DELETE(request: Request) { try { assertSameOrigin(request); - return json({ ok: true }, { headers: { "Set-Cookie": clearAdminCookie() } }); + return json({ ok: true }, { headers: { "Set-Cookie": clearTeacherCookie() } }); } catch (error) { return errorResponse(error); } diff --git a/gongju-moon-observation/app/api/health/route.ts b/gongju-moon-observation/app/api/health/route.ts index a773081..9d92520 100644 --- a/gongju-moon-observation/app/api/health/route.ts +++ b/gongju-moon-observation/app/api/health/route.ts @@ -1,12 +1,16 @@ import { json } from "../../../lib/http"; -import { getEnv } from "../../../lib/runtime"; +import { getEnv, isGoogleOAuthConfigured } from "../../../lib/runtime"; export async function GET() { try { const runtime = getEnv(); - await runtime.DB.prepare("SELECT 1 AS ok").first(); - return json({ ok: true }); + await Promise.all([ + runtime.DB.prepare("SELECT 1 FROM teachers LIMIT 1").first(), + runtime.DB.prepare("SELECT 1 FROM classes LIMIT 1").first(), + runtime.DB.prepare("SELECT 1 FROM submission_receipts LIMIT 1").first(), + ]); + return json({ ok: true, database: true, googleOAuth: isGoogleOAuthConfigured(runtime) }); } catch { - return json({ ok: false }, { status: 503 }); + return json({ ok: false, database: false, googleOAuth: false }, { status: 503 }); } } diff --git a/gongju-moon-observation/app/api/images/[id]/route.ts b/gongju-moon-observation/app/api/images/[id]/route.ts index 7198106..64a3467 100644 --- a/gongju-moon-observation/app/api/images/[id]/route.ts +++ b/gongju-moon-observation/app/api/images/[id]/route.ts @@ -1,46 +1,66 @@ -import { getAdminSession, getStudentSession } from "../../../../lib/auth"; -import { errorResponse, HttpError } from "../../../../lib/http"; -import { getClassId, getEnv } from "../../../../lib/runtime"; +import { getStudentSession, getTeacherSession } from "../../../../lib/auth"; +import { + assertObservationBelongsToClass, + downloadDriveFile, + getDriveFile, + getTeacherAccessToken, + googleDriveUserMessage, + parseObservationMetadata, +} from "../../../../lib/google-drive"; +import { errorResponse, HttpError, json } from "../../../../lib/http"; +import { getClassAndTeacher, getReceipt, getTeacherWorkspace } from "../../../../lib/tenants"; -interface ImageRow { - image_key: string; - image_type: string; - status: string; -} +const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -export async function GET( - request: Request, - context: { params: Promise<{ id: string }> }, -) { +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { try { - const admin = await getAdminSession(request); - const student = admin ? null : await getStudentSession(request); - if (!admin && !student) throw new HttpError(401, "수업 참여가 필요합니다."); + const { id: requestId } = await context.params; + if (!REQUEST_ID.test(requestId)) throw new HttpError(404, "사진을 찾을 수 없습니다."); + const receipt = await getReceipt(requestId); + if (!receipt) throw new HttpError(404, "사진을 찾을 수 없습니다."); + + const teacherSession = await getTeacherSession(request); + const studentSession = teacherSession ? null : await getStudentSession(request); + if (!teacherSession && !studentSession) { + throw new HttpError(401, "수업 참여 링크 또는 교사 로그인 후 이용해 주세요."); + } - const { id } = await context.params; - if (!/^[0-9a-f-]{36}$/i.test(id)) throw new HttpError(404, "사진을 찾을 수 없습니다."); - const runtime = getEnv(); - const row = await runtime.DB.prepare( - "SELECT image_key, image_type, status FROM observations WHERE id = ? AND class_id = ? LIMIT 1", - ) - .bind(id, getClassId(runtime)) - .first(); - if (!row || (!admin && row.status !== "visible")) { + const workspace = teacherSession + ? await getTeacherWorkspace(teacherSession.teacherId) + : await getClassAndTeacher(studentSession!.classId); + if (!workspace || receipt.classId !== workspace.classRecord.id) { + throw new HttpError(404, "사진을 찾을 수 없습니다."); + } + + const accessToken = await getTeacherAccessToken(request, workspace.teacher); + const file = await getDriveFile(accessToken, receipt.driveFileId); + assertObservationBelongsToClass( + file, + workspace.classRecord.driveFolderId, + workspace.classRecord.id, + ); + const metadata = parseObservationMetadata(file); + if (!metadata || metadata.requestId !== requestId) { + throw new HttpError(404, "사진 정보를 읽을 수 없습니다."); + } + if (studentSession && (!workspace.classRecord.galleryEnabled || metadata.status !== "visible")) { throw new HttpError(404, "사진을 찾을 수 없습니다."); } - const object = await runtime.BUCKET.get(row.image_key); - if (!object) throw new HttpError(404, "사진 파일을 찾을 수 없습니다."); + const driveResponse = await downloadDriveFile(accessToken, receipt.driveFileId); const headers = new Headers({ - "Cache-Control": "private, no-store", - "Content-Type": row.image_type, + "Cache-Control": "private, no-store, max-age=0", + "Content-Type": file.mimeType || driveResponse.headers.get("Content-Type") || "application/octet-stream", + "Content-Disposition": "inline", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "X-Robots-Tag": "noindex, nofollow, noarchive", }); - if (object.httpEtag) headers.set("ETag", object.httpEtag); - return new Response(object.body, { headers }); + const contentLength = driveResponse.headers.get("Content-Length"); + if (contentLength) headers.set("Content-Length", contentLength); + return new Response(driveResponse.body, { status: 200, headers }); } catch (error) { - return errorResponse(error); + const driveMessage = googleDriveUserMessage(error); + return driveMessage ? json({ message: driveMessage }, { status: 503 }) : errorResponse(error); } } diff --git a/gongju-moon-observation/app/api/oauth/google/callback/route.ts b/gongju-moon-observation/app/api/oauth/google/callback/route.ts new file mode 100644 index 0000000..d9b7a5e --- /dev/null +++ b/gongju-moon-observation/app/api/oauth/google/callback/route.ts @@ -0,0 +1,169 @@ +import { + clearOAuthStateCookie, + consumeOAuthState, + createTeacherCookie, +} from "../../../../../lib/auth"; +import { encryptString, randomToken, sha256Hex } from "../../../../../lib/crypto"; +import { + createDriveFolder, + exchangeAuthorizationCode, + getDriveFile, + getGoogleUserInfo, + GoogleApiError, +} from "../../../../../lib/google-drive"; +import { getTokenEncryptionSecret } from "../../../../../lib/runtime"; +import { + getClassByTeacherId, + getTeacherByGoogleSub, + insertClass, + insertTeacher, + updateClass, + updateTeacher, + type ClassRecord, + type TeacherRecord, +} from "../../../../../lib/tenants"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const returnTo = await consumeOAuthState(request, url.searchParams.get("state")); + if (!returnTo) return redirectWithMessage(request, "Google 연결 요청이 만료되었습니다. 다시 시도해 주세요."); + if (url.searchParams.get("error")) { + return redirectWithMessage(request, "Google Drive 연결이 취소되었습니다.", returnTo); + } + const code = url.searchParams.get("code"); + if (!code || code.length > 4096) { + return redirectWithMessage(request, "Google 연결 응답이 올바르지 않습니다.", returnTo); + } + + try { + const tokens = await exchangeAuthorizationCode(request, code); + if (!tokens.access_token) throw new Error("Google access token을 받지 못했습니다."); + const user = await getGoogleUserInfo(tokens.access_token); + if (!user.sub || !user.email || user.email_verified === false) { + throw new Error("확인된 Google 계정 이메일이 없습니다."); + } + + const existing = await getTeacherByGoogleSub(user.sub); + const encryptedRefreshToken = tokens.refresh_token + ? await encryptString(tokens.refresh_token, getTokenEncryptionSecret()) + : existing?.encryptedRefreshToken; + if (!encryptedRefreshToken) { + throw new Error("Google의 장기 연결 권한을 받지 못했습니다. 다시 연결해 주세요."); + } + + const now = new Date().toISOString(); + let teacher: TeacherRecord; + let classRecord: ClassRecord | null; + + if (existing) { + const rootFolderId = await ensureFolder( + tokens.access_token, + existing.rootFolderId, + "공주 달 관찰 탐험대", + ); + teacher = await updateTeacher({ + ...existing, + email: user.email, + displayName: user.name?.trim() || user.email, + encryptedRefreshToken, + rootFolderId, + updatedAt: now, + }); + classRecord = await getClassByTeacherId(existing.id); + if (classRecord) { + const driveFolderId = await ensureFolder( + tokens.access_token, + classRecord.driveFolderId, + classRecord.label, + rootFolderId, + ); + if (driveFolderId !== classRecord.driveFolderId) { + classRecord = await updateClass({ ...classRecord, driveFolderId, updatedAt: now }); + } + } else { + classRecord = await createInitialClass(tokens.access_token, teacher.id, rootFolderId, now); + } + } else { + const rootFolder = await createDriveFolder(tokens.access_token, "공주 달 관찰 탐험대"); + teacher = await insertTeacher({ + id: crypto.randomUUID(), + googleSub: user.sub, + email: user.email, + displayName: user.name?.trim() || user.email, + encryptedRefreshToken, + rootFolderId: rootFolder.id, + createdAt: now, + updatedAt: now, + }); + classRecord = await createInitialClass(tokens.access_token, teacher.id, teacher.rootFolderId, now); + } + + if (!classRecord) throw new Error("학급 제출함을 만들지 못했습니다."); + return redirectWithSession(request, returnTo, await createTeacherCookie(teacher.id)); + } catch (error) { + console.error("Google OAuth callback failed", error); + const message = error instanceof GoogleApiError + ? "Google Drive 폴더를 준비하지 못했습니다. 잠시 후 다시 연결해 주세요." + : error instanceof Error + ? error.message + : "Google Drive 연결을 완료하지 못했습니다."; + return redirectWithMessage(request, message, returnTo); + } +} + +async function createInitialClass( + accessToken: string, + teacherId: string, + rootFolderId: string, + now: string, +) { + const label = "4학년 1반"; + const folder = await createDriveFolder(accessToken, label, rootFolderId); + const inviteToken = randomToken(32); + return insertClass({ + id: crypto.randomUUID(), + teacherId, + label, + inviteTokenHash: await sha256Hex(inviteToken), + encryptedInviteToken: await encryptString(inviteToken, getTokenEncryptionSecret()), + driveFolderId: folder.id, + galleryEnabled: true, + createdAt: now, + updatedAt: now, + }); +} + +async function ensureFolder( + accessToken: string, + folderId: string, + name: string, + parentId?: string, +) { + try { + const file = await getDriveFile(accessToken, folderId); + if ( + file.mimeType === "application/vnd.google-apps.folder" && + file.appProperties?.app === "gongju-moon-observation" + ) return folderId; + } catch (error) { + if (!(error instanceof GoogleApiError) || error.status !== 404) throw error; + } + return (await createDriveFolder(accessToken, name, parentId)).id; +} + +function redirectWithSession(request: Request, returnTo: string, sessionCookie: string) { + const target = new URL(returnTo, request.url); + target.searchParams.set("connected", "1"); + const headers = new Headers({ Location: target.toString(), "Cache-Control": "no-store" }); + headers.append("Set-Cookie", sessionCookie); + headers.append("Set-Cookie", clearOAuthStateCookie()); + return new Response(null, { status: 302, headers }); +} + +function redirectWithMessage(request: Request, message: string, returnTo = "/admin") { + const target = new URL(returnTo, request.url); + target.searchParams.set("error", message.slice(0, 240)); + const headers = new Headers({ Location: target.toString(), "Cache-Control": "no-store" }); + headers.append("Set-Cookie", clearOAuthStateCookie()); + return new Response(null, { status: 302, headers }); +} diff --git a/gongju-moon-observation/app/api/oauth/google/start/route.ts b/gongju-moon-observation/app/api/oauth/google/start/route.ts new file mode 100644 index 0000000..5f0c31c --- /dev/null +++ b/gongju-moon-observation/app/api/oauth/google/start/route.ts @@ -0,0 +1,26 @@ +import { createOAuthState } from "../../../../../lib/auth"; +import { buildGoogleAuthorizationUrl } from "../../../../../lib/google-drive"; +import { isGoogleOAuthConfigured } from "../../../../../lib/runtime"; + +export async function GET(request: Request) { + if (!isGoogleOAuthConfigured()) { + return redirectToAdmin(request, "Google OAuth 서버 설정이 아직 완료되지 않았습니다."); + } + const url = new URL(request.url); + const { state, setCookie } = await createOAuthState(url.searchParams.get("returnTo") || "/admin"); + return new Response(null, { + status: 302, + headers: { + Location: buildGoogleAuthorizationUrl(request, state), + "Set-Cookie": setCookie, + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + }, + }); +} + +function redirectToAdmin(request: Request, message: string) { + const url = new URL("/admin", request.url); + url.searchParams.set("error", message); + return Response.redirect(url, 302); +} diff --git a/gongju-moon-observation/app/api/observations/route.ts b/gongju-moon-observation/app/api/observations/route.ts index fe30cfd..802cf21 100644 --- a/gongju-moon-observation/app/api/observations/route.ts +++ b/gongju-moon-observation/app/api/observations/route.ts @@ -1,175 +1,172 @@ -import { getAdminSession, getStudentSession } from "../../../lib/auth"; +import { getStudentSession } from "../../../lib/auth"; +import { + deleteDriveFile, + getTeacherAccessToken, + googleDriveUserMessage, + listObservationFiles, + parseObservationMetadata, + uploadObservationFile, +} from "../../../lib/google-drive"; import { assertSameOrigin, errorResponse, HttpError, json } from "../../../lib/http"; import { - decodeCursor, detectImageType, - encodeCursor, maskStudentName, stripImageMetadata, validateObservationForm, } from "../../../lib/observations"; -import { getClassId, getEnv } from "../../../lib/runtime"; - -interface ObservationRow { - id: string; - student_number: number; - student_name: string; - observed_at: string; - memo: string; - created_at: string; -} +import { + countRecentSubmissions, + countVisibleReceipts, + getClassAndTeacher, + getReceipt, + insertReceipt, +} from "../../../lib/tenants"; export async function GET(request: Request) { try { - const viewer = (await getAdminSession(request)) || (await getStudentSession(request)); - if (!viewer) throw new HttpError(401, "수업 참여 링크로 입장한 뒤 이용해 주세요."); + const session = await getStudentSession(request); + if (!session) throw new HttpError(401, "수업 참여 링크로 입장한 뒤 이용해 주세요."); + const workspace = await getClassAndTeacher(session.classId); + if (!workspace) throw new HttpError(404, "수업 제출함을 찾을 수 없습니다."); + if (!workspace.classRecord.galleryEnabled) { + throw new HttpError(403, "선생님이 우리 반 갤러리를 사용하지 않도록 설정했습니다."); + } - const runtime = getEnv(); - const classId = getClassId(runtime); const url = new URL(request.url); const limit = Math.min(24, Math.max(1, Number(url.searchParams.get("limit")) || 12)); - const cursorValue = url.searchParams.get("cursor"); - const cursor = decodeCursor(cursorValue); - if (cursorValue && !cursor) throw new HttpError(400, "갤러리 이어보기 정보가 올바르지 않습니다."); - - const statement = cursor - ? runtime.DB.prepare( - `SELECT id, student_number, student_name, observed_at, memo, created_at - FROM observations - WHERE class_id = ? AND status = 'visible' - AND (created_at < ? OR (created_at = ? AND id < ?)) - ORDER BY created_at DESC, id DESC - LIMIT ?`, - ).bind(classId, cursor.createdAt, cursor.createdAt, cursor.id, limit + 1) - : runtime.DB.prepare( - `SELECT id, student_number, student_name, observed_at, memo, created_at - FROM observations - WHERE class_id = ? AND status = 'visible' - ORDER BY created_at DESC, id DESC - LIMIT ?`, - ).bind(classId, limit + 1); - - const [page, totalRow] = await Promise.all([ - statement.all(), - runtime.DB.prepare( - "SELECT COUNT(*) AS total FROM observations WHERE class_id = ? AND status = 'visible'", - ) - .bind(classId) - .first<{ total: number }>(), + const cursor = normalizePageToken(url.searchParams.get("cursor")); + const accessToken = await getTeacherAccessToken(request, workspace.teacher); + const [page, total] = await Promise.all([ + listObservationFiles({ + accessToken, + folderId: workspace.classRecord.driveFolderId, + classId: workspace.classRecord.id, + pageToken: cursor, + pageSize: limit, + visibleOnly: true, + }), + countVisibleReceipts(workspace.classRecord.id), ]); - const rows = page.results || []; - const hasMore = rows.length > limit; - const visibleRows = rows.slice(0, limit); - const last = visibleRows.at(-1); + + const items = (page.files || []).flatMap((file) => { + const metadata = parseObservationMetadata(file); + if (!metadata || metadata.status !== "visible") return []; + return [{ + id: metadata.requestId, + studentNumber: metadata.studentNumber, + displayName: maskStudentName(metadata.studentName), + observedAt: metadata.observedAt, + memo: metadata.memo, + imageUrl: `/api/images/${encodeURIComponent(metadata.requestId)}`, + }]; + }); return json({ - items: visibleRows.map((row) => ({ - id: row.id, - studentNumber: row.student_number, - displayName: maskStudentName(row.student_name), - observedAt: row.observed_at, - memo: row.memo, - imageUrl: `/api/images/${row.id}`, - })), - total: Number(totalRow?.total || 0), - hasMore, - nextCursor: hasMore && last ? encodeCursor(last.created_at, last.id) : null, + items, + total, + hasMore: Boolean(page.nextPageToken), + nextCursor: page.nextPageToken || null, + classLabel: workspace.classRecord.label, }); } catch (error) { - return errorResponse(error); + const driveMessage = googleDriveUserMessage(error); + return driveMessage ? json({ message: driveMessage }, { status: 503 }) : errorResponse(error); } } export async function POST(request: Request) { - let storedImageKey: string | null = null; + let uploadedFileId: string | null = null; try { assertSameOrigin(request); const session = await getStudentSession(request); if (!session) throw new HttpError(401, "수업 참여 링크로 입장한 뒤 제출해 주세요."); const contentLength = Number(request.headers.get("Content-Length") || 0); - if (contentLength > 4 * 1024 * 1024) throw new HttpError(413, "제출 사진의 용량이 너무 큽니다."); + if (contentLength > 4 * 1024 * 1024) { + throw new HttpError(413, "제출 사진의 용량이 너무 큽니다."); + } + const workspace = await getClassAndTeacher(session.classId); + if (!workspace) throw new HttpError(404, "수업 제출함을 찾을 수 없습니다."); - const runtime = getEnv(); - const classId = getClassId(runtime); const input = validateObservationForm(await request.formData()); - - const existing = await runtime.DB.prepare( - "SELECT id, session_id FROM observations WHERE request_id = ? LIMIT 1", - ) - .bind(input.requestId) - .first<{ id: string; session_id: string }>(); + const existing = await getReceipt(input.requestId); if (existing) { - if (existing.session_id !== session.sid) throw new HttpError(409, "이미 처리된 제출 요청입니다."); - return json({ ok: true, id: existing.id, message: "이미 제출된 사진입니다." }); + if (existing.classId !== session.classId || existing.sessionId !== session.sid) { + throw new HttpError(409, "이미 처리된 제출 요청입니다."); + } + return json({ ok: true, id: existing.driveFileId, message: "이미 제출된 사진입니다." }); } - const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString(); - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - const [deviceCount, classCount] = await Promise.all([ - runtime.DB.prepare( - "SELECT COUNT(*) AS count FROM observations WHERE session_id = ? AND created_at >= ?", - ) - .bind(session.sid, tenMinutesAgo) - .first<{ count: number }>(), - runtime.DB.prepare( - "SELECT COUNT(*) AS count FROM observations WHERE class_id = ? AND created_at >= ?", - ) - .bind(classId, oneHourAgo) - .first<{ count: number }>(), - ]); - if (Number(deviceCount?.count || 0) >= 3) { + const recent = await countRecentSubmissions(session.sid, session.classId); + if (recent.deviceCount >= 3) { throw new HttpError(429, "10분 동안 제출할 수 있는 횟수를 넘었습니다. 잠시 후 다시 시도해 주세요."); } - if (Number(classCount?.count || 0) >= 100) { + if (recent.classCount >= 100) { throw new HttpError(429, "현재 제출이 많습니다. 잠시 후 다시 시도해 주세요."); } - const uploadedBytes = new Uint8Array(await input.photo.arrayBuffer()); - const image = detectImageType(uploadedBytes); - const bytes = stripImageMetadata(uploadedBytes, image.contentType); - const id = crypto.randomUUID(); + const sourceBytes = new Uint8Array(await input.photo.arrayBuffer()); + const image = detectImageType(sourceBytes); + const bytes = stripImageMetadata(sourceBytes, image.contentType); const createdAt = new Date().toISOString(); - storedImageKey = `classes/${classId}/observations/${id}.${image.extension}`; - - await runtime.BUCKET.put(storedImageKey, bytes, { - httpMetadata: { contentType: image.contentType }, - customMetadata: { observationId: id }, + const fileName = `${createdAt.replace(/[:.]/g, "-")}-${crypto.randomUUID()}.${image.extension}`; + const accessToken = await getTeacherAccessToken(request, workspace.teacher); + const uploaded = await uploadObservationFile({ + accessToken, + folderId: workspace.classRecord.driveFolderId, + classId: workspace.classRecord.id, + fileName, + contentType: image.contentType, + bytes, + metadata: { + schemaVersion: 1, + studentNumber: input.studentNumber, + studentName: input.studentName, + observedAt: input.observedAt, + memo: input.memo, + status: "visible", + requestId: input.requestId, + createdAt, + }, }); + uploadedFileId = uploaded.id; try { - await runtime.DB.prepare( - `INSERT INTO observations - (id, request_id, class_id, session_id, student_number, student_name, - observed_at, memo, image_key, image_type, image_bytes, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'visible', ?)`, - ) - .bind( - id, - input.requestId, - classId, - session.sid, - input.studentNumber, - input.studentName, - input.observedAt, - input.memo, - storedImageKey, - image.contentType, - bytes.byteLength, - createdAt, - ) - .run(); + await insertReceipt({ + requestId: input.requestId, + classId: session.classId, + sessionId: session.sid, + driveFileId: uploaded.id, + status: "visible", + createdAt, + }); } catch (error) { - await runtime.BUCKET.delete(storedImageKey); - storedImageKey = null; + await deleteDriveFile(accessToken, uploaded.id).catch((cleanupError) => { + console.warn("Drive upload rollback failed", cleanupError); + }); + uploadedFileId = null; + const raced = await getReceipt(input.requestId); + if (raced && raced.classId === session.classId && raced.sessionId === session.sid) { + return json({ ok: true, id: raced.driveFileId, message: "이미 제출된 사진입니다." }); + } throw error; } return json( - { ok: true, id, message: "달 관찰 사진을 제출했습니다." }, + { ok: true, id: uploaded.id, message: "선생님의 Google Drive에 달 관찰 사진을 제출했습니다." }, { status: 201 }, ); } catch (error) { - return errorResponse(error); + if (uploadedFileId) console.warn("Submission failed after Drive upload", uploadedFileId); + const driveMessage = googleDriveUserMessage(error); + return driveMessage ? json({ message: driveMessage }, { status: 503 }) : errorResponse(error); + } +} + +function normalizePageToken(value: string | null) { + if (!value) return null; + if (value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) { + throw new HttpError(400, "갤러리 이어보기 정보가 올바르지 않습니다."); } + return value; } diff --git a/gongju-moon-observation/app/api/session/route.ts b/gongju-moon-observation/app/api/session/route.ts index db350d8..d1ff76a 100644 --- a/gongju-moon-observation/app/api/session/route.ts +++ b/gongju-moon-observation/app/api/session/route.ts @@ -2,17 +2,28 @@ import { clearStudentCookie, createStudentCookie, getStudentSession, - safeSecretEqual, } from "../../../lib/auth"; +import { sha256Hex } from "../../../lib/crypto"; import { assertSameOrigin, errorResponse, HttpError, json } from "../../../lib/http"; -import { getClassLabel, getEnv } from "../../../lib/runtime"; +import { getClassById, getClassByInviteHash } from "../../../lib/tenants"; export async function GET(request: Request) { try { const session = await getStudentSession(request); + if (!session) { + return json({ authenticated: false, classLabel: null, galleryEnabled: false }); + } + const classRecord = await getClassById(session.classId); + if (!classRecord) { + return json( + { authenticated: false, classLabel: null, galleryEnabled: false }, + { headers: { "Set-Cookie": clearStudentCookie() } }, + ); + } return json({ - authenticated: Boolean(session), - classLabel: session ? getClassLabel() : null, + authenticated: true, + classLabel: classRecord.label, + galleryEnabled: classRecord.galleryEnabled, }); } catch (error) { return errorResponse(error); @@ -26,13 +37,17 @@ export async function POST(request: Request) { if (typeof payload.token !== "string" || payload.token.length < 32 || payload.token.length > 256) { throw new HttpError(401, "유효하지 않거나 만료된 수업 참여 링크입니다."); } - if (!(await safeSecretEqual(payload.token, getEnv().CLASS_INVITE_TOKEN))) { + const classRecord = await getClassByInviteHash(await sha256Hex(payload.token)); + if (!classRecord) { throw new HttpError(401, "유효하지 않거나 만료된 수업 참여 링크입니다."); } - return json( - { ok: true, classLabel: getClassLabel() }, - { headers: { "Set-Cookie": await createStudentCookie() } }, + { + ok: true, + classLabel: classRecord.label, + galleryEnabled: classRecord.galleryEnabled, + }, + { headers: { "Set-Cookie": await createStudentCookie(classRecord.id) } }, ); } catch (error) { return errorResponse(error); diff --git a/gongju-moon-observation/app/join/page.tsx b/gongju-moon-observation/app/join/page.tsx index a3a4048..eae22ac 100644 --- a/gongju-moon-observation/app/join/page.tsx +++ b/gongju-moon-observation/app/join/page.tsx @@ -28,7 +28,7 @@ export default function JoinPage() { const result = (await response.json().catch(() => ({}))) as { message?: string }; if (!response.ok) throw new Error(result.message || "수업 참여 링크를 확인하지 못했습니다."); setMessage("수업 참여가 확인되었습니다. 탐험대로 이동할게요!"); - window.setTimeout(() => window.location.replace("/"), 500); + window.setTimeout(() => window.location.replace("/index.html"), 350); }) .catch((error: unknown) => { setFailed(true); @@ -41,16 +41,10 @@ export default function JoinPage() {

공주 달 관찰 탐험대

-

- {message} -

+

{message}

{failed ? ( - - 달 정보 먼저 보기 - - ) : ( -
); diff --git a/gongju-moon-observation/app/layout.tsx b/gongju-moon-observation/app/layout.tsx index 3a4039c..3993f06 100644 --- a/gongju-moon-observation/app/layout.tsx +++ b/gongju-moon-observation/app/layout.tsx @@ -1,8 +1,12 @@ import type { Metadata } from "next"; import "./globals.css"; +const metadataOrigin = + process.env.NEXT_PUBLIC_SITE_ORIGIN?.trim() || + "https://classroom-webapp-2026.znr1.chatgpt.site"; + export const metadata: Metadata = { - metadataBase: new URL("https://classroom-webapp-2026.znr1.chatgpt.site"), + metadataBase: new URL(metadataOrigin), title: "공주 달 관찰 탐험대", description: "공주시 초등학생을 위한 달 모양·관찰 시간 확인과 사진 기록 활동", robots: { index: false, follow: false, noarchive: true }, diff --git a/gongju-moon-observation/app/page.tsx b/gongju-moon-observation/app/page.tsx index 8bb3249..72e612b 100644 --- a/gongju-moon-observation/app/page.tsx +++ b/gongju-moon-observation/app/page.tsx @@ -1,11 +1,5 @@ -import Link from "next/link"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
- - 공주 달 관찰 탐험대 시작하기 - -
- ); + redirect("/index.html"); } diff --git a/gongju-moon-observation/app/privacy/page.tsx b/gongju-moon-observation/app/privacy/page.tsx new file mode 100644 index 0000000..b01cc88 --- /dev/null +++ b/gongju-moon-observation/app/privacy/page.tsx @@ -0,0 +1,45 @@ +import Link from "next/link"; + +export default function PrivacyPage() { + return ( +
+
+

공주 달 관찰 탐험대

+

개인정보 처리 안내

+

최종 수정: 2026년 8월 24일

+ +
+

1. 서비스의 역할

+

이 서비스는 교사가 자신의 Google Drive를 연결하고, 학생의 달 관찰 사진과 기록을 해당 교사의 앱 전용 Drive 폴더로 수합하도록 중계합니다.

+
+
+

2. 중앙 서비스가 처리하는 교사 정보

+

Google 계정 식별자, 이메일, 표시 이름, 암호화된 refresh token, 앱이 만든 Drive 폴더 ID, 학급 설정과 수업 초대 정보를 처리합니다. refresh token은 서버에서 AES-GCM으로 암호화해 저장합니다.

+
+
+

3. 학생 자료의 저장 위치

+

학생 사진·이름·번호·관찰 시각·메모는 제출 처리 중 중앙 서버를 통과하지만 중앙 D1 또는 R2에 장기 저장하지 않습니다. 검증과 사진 메타데이터 제거 후 교사의 Google Drive 파일에 저장합니다.

+

중앙 D1에는 중복 제출 방지와 요청 제한을 위한 무작위 요청 ID, 학급 ID, 세션 ID, Drive 파일 ID, 상태와 시각만 남습니다.

+
+
+

4. Google 권한

+

교사 식별을 위한 openid email과, 앱이 생성하거나 연결한 파일만 관리하는 drive.file만 요청합니다. 사용자의 Drive 전체를 읽는 권한은 요청하지 않습니다.

+
+
+

5. 연결 해제와 삭제

+

교사는 관리 화면에서 연결을 해제할 수 있습니다. 연결 해제 시 중앙 D1의 교사 연결 정보·암호화 토큰·학급 정보·비식별 제출 영수증을 삭제하고 Google 토큰 폐기를 요청합니다.

+

교사가 소유한 Google Drive 폴더와 사진은 자동 삭제하지 않습니다. 교사는 Drive에서 직접 보관하거나 삭제할 수 있습니다.

+
+
+

6. 문의

+

운영 및 개인정보 문의는 Google OAuth 동의 화면에 표시된 지원 이메일로 접수합니다.

+
+ +
+ 교사 관리로 돌아가기 + 이용 안내 +
+
+
+ ); +} diff --git a/gongju-moon-observation/app/terms/page.tsx b/gongju-moon-observation/app/terms/page.tsx new file mode 100644 index 0000000..0598242 --- /dev/null +++ b/gongju-moon-observation/app/terms/page.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; + +export default function TermsPage() { + return ( +
+
+

공주 달 관찰 탐험대

+

서비스 이용 안내

+

최종 수정: 2026년 8월 24일

+
+

교육 목적

이 서비스는 학교 과학 수업의 달 관찰 활동과 자료 수합을 돕기 위한 도구입니다. 천문 계산값은 교육용 참고값이며 실제 관찰 환경과 차이가 있을 수 있습니다.

+

교사의 책임

교사는 학생과 보호자에게 활동 목적, 제출 자료의 범위, 보관 기간과 공개 범위를 안내하고 학교의 개인정보·사진 촬영 지침을 준수해야 합니다. 학생 갤러리가 불필요한 경우 관리 화면에서 끄는 것을 권장합니다.

+

학생 촬영 안전

얼굴, 집 주소, 차량 번호, 정확한 위치 등 불필요한 개인정보가 보이지 않도록 달과 하늘을 중심으로 촬영해야 합니다. 사진의 EXIF 위치정보는 제출 과정에서 제거합니다.

+

Google Drive

제출 파일은 교사가 소유한 Google Drive 공간을 사용합니다. Drive 용량 부족, 학교 관리자 정책, 교사의 권한 해제 또는 Google API 장애로 제출이 일시 중단될 수 있습니다.

+

서비스 변경

보안, 법령, Google API 정책 또는 교육 현장의 필요에 따라 기능과 운영 방식이 변경될 수 있습니다. 중요한 변경은 서비스 화면 또는 저장소 문서에 안내합니다.

+
+
+ 교사 관리로 돌아가기 + 개인정보 처리 안내 +
+
+
+ ); +} diff --git a/gongju-moon-observation/db/schema.ts b/gongju-moon-observation/db/schema.ts index b9fe862..fe326e5 100644 --- a/gongju-moon-observation/db/schema.ts +++ b/gongju-moon-observation/db/schema.ts @@ -1,6 +1,8 @@ import { sql } from "drizzle-orm"; import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +// Kept for schema compatibility with the previous D1/R2 release. +// New submissions do not write student data to this table. export const observations = sqliteTable( "observations", { @@ -26,3 +28,59 @@ export const observations = sqliteTable( index("observations_status_created_idx").on(table.status, table.createdAt), ], ); + +export const teachers = sqliteTable( + "teachers", + { + id: text("id").primaryKey(), + googleSub: text("google_sub").notNull(), + email: text("email").notNull(), + displayName: text("display_name").notNull(), + encryptedRefreshToken: text("encrypted_refresh_token").notNull(), + rootFolderId: text("root_folder_id").notNull(), + createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("teachers_google_sub_unique").on(table.googleSub), + index("teachers_email_idx").on(table.email), + ], +); + +export const classes = sqliteTable( + "classes", + { + id: text("id").primaryKey(), + teacherId: text("teacher_id").notNull(), + label: text("label").notNull(), + inviteTokenHash: text("invite_token_hash").notNull(), + encryptedInviteToken: text("encrypted_invite_token").notNull(), + driveFolderId: text("drive_folder_id").notNull(), + galleryEnabled: integer("gallery_enabled", { mode: "boolean" }).notNull().default(true), + createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("classes_teacher_unique").on(table.teacherId), + uniqueIndex("classes_invite_token_hash_unique").on(table.inviteTokenHash), + index("classes_drive_folder_idx").on(table.driveFolderId), + ], +); + +export const submissionReceipts = sqliteTable( + "submission_receipts", + { + requestId: text("request_id").primaryKey(), + classId: text("class_id").notNull(), + sessionId: text("session_id").notNull(), + driveFileId: text("drive_file_id").notNull(), + status: text("status").notNull().default("visible"), + createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + }, + (table) => [ + uniqueIndex("submission_receipts_drive_file_unique").on(table.driveFileId), + index("submission_receipts_class_created_idx").on(table.classId, table.createdAt), + index("submission_receipts_session_created_idx").on(table.sessionId, table.createdAt), + index("submission_receipts_status_idx").on(table.classId, table.status), + ], +); diff --git a/gongju-moon-observation/drizzle-cloudflare/0000_central_drive_oauth.sql b/gongju-moon-observation/drizzle-cloudflare/0000_central_drive_oauth.sql new file mode 100644 index 0000000..92ced0a --- /dev/null +++ b/gongju-moon-observation/drizzle-cloudflare/0000_central_drive_oauth.sql @@ -0,0 +1,49 @@ +CREATE TABLE `teachers` ( + `id` text PRIMARY KEY NOT NULL, + `google_sub` text NOT NULL, + `email` text NOT NULL, + `display_name` text NOT NULL, + `encrypted_refresh_token` text NOT NULL, + `root_folder_id` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `teachers_google_sub_unique` ON `teachers` (`google_sub`); +--> statement-breakpoint +CREATE INDEX `teachers_email_idx` ON `teachers` (`email`); +--> statement-breakpoint +CREATE TABLE `classes` ( + `id` text PRIMARY KEY NOT NULL, + `teacher_id` text NOT NULL, + `label` text NOT NULL, + `invite_token_hash` text NOT NULL, + `encrypted_invite_token` text NOT NULL, + `drive_folder_id` text NOT NULL, + `gallery_enabled` integer DEFAULT 1 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `classes_teacher_unique` ON `classes` (`teacher_id`); +--> statement-breakpoint +CREATE UNIQUE INDEX `classes_invite_token_hash_unique` ON `classes` (`invite_token_hash`); +--> statement-breakpoint +CREATE INDEX `classes_drive_folder_idx` ON `classes` (`drive_folder_id`); +--> statement-breakpoint +CREATE TABLE `submission_receipts` ( + `request_id` text PRIMARY KEY NOT NULL, + `class_id` text NOT NULL, + `session_id` text NOT NULL, + `drive_file_id` text NOT NULL, + `status` text DEFAULT 'visible' NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `submission_receipts_drive_file_unique` ON `submission_receipts` (`drive_file_id`); +--> statement-breakpoint +CREATE INDEX `submission_receipts_class_created_idx` ON `submission_receipts` (`class_id`,`created_at`); +--> statement-breakpoint +CREATE INDEX `submission_receipts_session_created_idx` ON `submission_receipts` (`session_id`,`created_at`); +--> statement-breakpoint +CREATE INDEX `submission_receipts_status_idx` ON `submission_receipts` (`class_id`,`status`); diff --git a/gongju-moon-observation/drizzle/0001_central_drive_oauth.sql b/gongju-moon-observation/drizzle/0001_central_drive_oauth.sql new file mode 100644 index 0000000..92ced0a --- /dev/null +++ b/gongju-moon-observation/drizzle/0001_central_drive_oauth.sql @@ -0,0 +1,49 @@ +CREATE TABLE `teachers` ( + `id` text PRIMARY KEY NOT NULL, + `google_sub` text NOT NULL, + `email` text NOT NULL, + `display_name` text NOT NULL, + `encrypted_refresh_token` text NOT NULL, + `root_folder_id` text NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `teachers_google_sub_unique` ON `teachers` (`google_sub`); +--> statement-breakpoint +CREATE INDEX `teachers_email_idx` ON `teachers` (`email`); +--> statement-breakpoint +CREATE TABLE `classes` ( + `id` text PRIMARY KEY NOT NULL, + `teacher_id` text NOT NULL, + `label` text NOT NULL, + `invite_token_hash` text NOT NULL, + `encrypted_invite_token` text NOT NULL, + `drive_folder_id` text NOT NULL, + `gallery_enabled` integer DEFAULT 1 NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `classes_teacher_unique` ON `classes` (`teacher_id`); +--> statement-breakpoint +CREATE UNIQUE INDEX `classes_invite_token_hash_unique` ON `classes` (`invite_token_hash`); +--> statement-breakpoint +CREATE INDEX `classes_drive_folder_idx` ON `classes` (`drive_folder_id`); +--> statement-breakpoint +CREATE TABLE `submission_receipts` ( + `request_id` text PRIMARY KEY NOT NULL, + `class_id` text NOT NULL, + `session_id` text NOT NULL, + `drive_file_id` text NOT NULL, + `status` text DEFAULT 'visible' NOT NULL, + `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `submission_receipts_drive_file_unique` ON `submission_receipts` (`drive_file_id`); +--> statement-breakpoint +CREATE INDEX `submission_receipts_class_created_idx` ON `submission_receipts` (`class_id`,`created_at`); +--> statement-breakpoint +CREATE INDEX `submission_receipts_session_created_idx` ON `submission_receipts` (`session_id`,`created_at`); +--> statement-breakpoint +CREATE INDEX `submission_receipts_status_idx` ON `submission_receipts` (`class_id`,`status`); diff --git a/gongju-moon-observation/eslint.config.mjs b/gongju-moon-observation/eslint.config.mjs index 05e726d..2d4ed1f 100644 --- a/gongju-moon-observation/eslint.config.mjs +++ b/gongju-moon-observation/eslint.config.mjs @@ -5,6 +5,15 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + files: ["app/admin/page.tsx"], + rules: { + // The page intentionally hydrates an external OAuth callback/query state on mount. + "react-hooks/set-state-in-effect": "off", + // Authenticated Drive images must be requested directly so session cookies reach the proxy route. + "@next/next/no-img-element": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/gongju-moon-observation/lib/auth.ts b/gongju-moon-observation/lib/auth.ts index c84ee04..2cad67c 100644 --- a/gongju-moon-observation/lib/auth.ts +++ b/gongju-moon-observation/lib/auth.ts @@ -1,85 +1,131 @@ -import { getClassId, getEnv } from "./runtime"; +import { randomToken, signPayload, verifyPayload } from "./crypto"; +import { getEnv } from "./runtime"; const STUDENT_COOKIE = "moon_class_session"; -const ADMIN_COOKIE = "moon_admin_session"; -const STUDENT_SESSION_MAX_AGE = 60 * 24 * 60 * 60; -const encoder = new TextEncoder(); - -export interface SessionPayload { - role: "student" | "admin"; +const TEACHER_COOKIE = "moon_teacher_session"; +const OAUTH_STATE_COOKIE = "moon_google_oauth_state"; +export const STUDENT_SESSION_MAX_AGE = 60 * 24 * 60 * 60; +const TEACHER_SESSION_MAX_AGE = 12 * 60 * 60; +const OAUTH_STATE_MAX_AGE = 10 * 60; + +export interface StudentSession { + role: "student"; classId: string; sid: string; exp: number; } -function toBase64Url(bytes: Uint8Array) { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +export interface TeacherSession { + role: "teacher"; + teacherId: string; + sid: string; + exp: number; +} + +interface OAuthStatePayload { + state: string; + returnTo: string; + exp: number; } -function fromBase64Url(value: string) { - const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); - const binary = atob(padded); - return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +export async function createStudentCookie(classId: string) { + const now = Math.floor(Date.now() / 1000); + const payload: StudentSession = { + role: "student", + classId, + sid: crypto.randomUUID(), + exp: now + STUDENT_SESSION_MAX_AGE, + }; + return cookie(STUDENT_COOKIE, await signPayload(payload, getEnv().SESSION_SECRET), STUDENT_SESSION_MAX_AGE); } -async function hmac(value: string) { - const runtime = getEnv(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(runtime.SESSION_SECRET), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], +export async function createTeacherCookie(teacherId: string) { + const now = Math.floor(Date.now() / 1000); + const payload: TeacherSession = { + role: "teacher", + teacherId, + sid: crypto.randomUUID(), + exp: now + TEACHER_SESSION_MAX_AGE, + }; + return cookie(TEACHER_COOKIE, await signPayload(payload, getEnv().SESSION_SECRET), TEACHER_SESSION_MAX_AGE); +} + +export async function createOAuthState(returnTo = "/admin") { + const state = randomToken(24); + const payload: OAuthStatePayload = { + state, + returnTo: safeRelativeReturnPath(returnTo), + exp: Math.floor(Date.now() / 1000) + OAUTH_STATE_MAX_AGE, + }; + return { + state, + setCookie: cookie( + OAUTH_STATE_COOKIE, + await signPayload(payload, getEnv().SESSION_SECRET), + OAUTH_STATE_MAX_AGE, + ), + }; +} + +export async function consumeOAuthState(request: Request, state: string | null) { + const payload = await verifyPayload( + readCookie(request, OAUTH_STATE_COOKIE), + getEnv().SESSION_SECRET, ); - return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(value))); + if (!state || !payload || payload.state !== state || payload.exp <= Math.floor(Date.now() / 1000)) { + return null; + } + return payload.returnTo; } -export async function sha256Hex(value: string) { - const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))); - return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +export async function getStudentSession(request: Request) { + const payload = await verifyPayload( + readCookie(request, STUDENT_COOKIE), + getEnv().SESSION_SECRET, + ); + if ( + !payload || + payload.role !== "student" || + !payload.classId || + !payload.sid || + payload.exp <= Math.floor(Date.now() / 1000) + ) { + return null; + } + return payload; } -export async function safeSecretEqual(left: string, right: string) { - const [leftHash, rightHash] = await Promise.all([sha256Hex(left), sha256Hex(right)]); - let mismatch = leftHash.length ^ rightHash.length; - const length = Math.max(leftHash.length, rightHash.length); - for (let index = 0; index < length; index += 1) { - mismatch |= (leftHash.charCodeAt(index) || 0) ^ (rightHash.charCodeAt(index) || 0); +export async function getTeacherSession(request: Request) { + const payload = await verifyPayload( + readCookie(request, TEACHER_COOKIE), + getEnv().SESSION_SECRET, + ); + if ( + !payload || + payload.role !== "teacher" || + !payload.teacherId || + !payload.sid || + payload.exp <= Math.floor(Date.now() / 1000) + ) { + return null; } - return mismatch === 0; + return payload; } -async function signSession(payload: SessionPayload) { - const encoded = toBase64Url(encoder.encode(JSON.stringify(payload))); - const signature = toBase64Url(await hmac(encoded)); - return `${encoded}.${signature}`; +export async function getViewerSession(request: Request) { + return (await getTeacherSession(request)) || (await getStudentSession(request)); } -async function verifySession(value: string | null, role: SessionPayload["role"]) { - if (!value) return null; - const [encoded, signature, extra] = value.split("."); - if (!encoded || !signature || extra) return null; - const expected = toBase64Url(await hmac(encoded)); - if (!(await safeSecretEqual(signature, expected))) return null; +export function clearStudentCookie() { + return cookie(STUDENT_COOKIE, "", 0); +} - try { - const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(encoded))) as SessionPayload; - if ( - payload.role !== role || - payload.classId !== getClassId() || - !payload.sid || - !Number.isFinite(payload.exp) || - payload.exp <= Math.floor(Date.now() / 1000) - ) { - return null; - } - return payload; - } catch { - return null; - } +export function clearTeacherCookie() { + return cookie(TEACHER_COOKIE, "", 0); +} + +export function clearOAuthStateCookie() { + return cookie(OAUTH_STATE_COOKIE, "", 0); } function readCookie(request: Request, name: string) { @@ -95,44 +141,13 @@ function cookie(name: string, value: string, maxAge: number) { return `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`; } -export async function createStudentCookie() { - const maxAge = STUDENT_SESSION_MAX_AGE; - const value = await signSession({ - role: "student", - classId: getClassId(), - sid: crypto.randomUUID(), - exp: Math.floor(Date.now() / 1000) + maxAge, - }); - return cookie(STUDENT_COOKIE, value, maxAge); -} - -export async function createAdminCookie() { - const maxAge = 12 * 60 * 60; - const value = await signSession({ - role: "admin", - classId: getClassId(), - sid: crypto.randomUUID(), - exp: Math.floor(Date.now() / 1000) + maxAge, - }); - return cookie(ADMIN_COOKIE, value, maxAge); -} - -export function clearStudentCookie() { - return cookie(STUDENT_COOKIE, "", 0); -} - -export function clearAdminCookie() { - return cookie(ADMIN_COOKIE, "", 0); -} - -export function getStudentSession(request: Request) { - return verifySession(readCookie(request, STUDENT_COOKIE), "student"); -} - -export function getAdminSession(request: Request) { - return verifySession(readCookie(request, ADMIN_COOKIE), "admin"); -} - -export async function getViewerSession(request: Request) { - return (await getAdminSession(request)) || (await getStudentSession(request)); +function safeRelativeReturnPath(value: string) { + if (!value.startsWith("/") || value.startsWith("//")) return "/admin"; + try { + const url = new URL(value, "https://app.local"); + if (url.origin !== "https://app.local") return "/admin"; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return "/admin"; + } } diff --git a/gongju-moon-observation/lib/crypto.ts b/gongju-moon-observation/lib/crypto.ts new file mode 100644 index 0000000..9390d45 --- /dev/null +++ b/gongju-moon-observation/lib/crypto.ts @@ -0,0 +1,95 @@ +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function randomToken(byteLength = 32) { + return toBase64Url(crypto.getRandomValues(new Uint8Array(byteLength))); +} + +export async function sha256Hex(value: string) { + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value))); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export async function safeSecretEqual(left: string, right: string) { + const [leftHash, rightHash] = await Promise.all([sha256Hex(left), sha256Hex(right)]); + let mismatch = leftHash.length ^ rightHash.length; + const length = Math.max(leftHash.length, rightHash.length); + for (let index = 0; index < length; index += 1) { + mismatch |= (leftHash.charCodeAt(index) || 0) ^ (rightHash.charCodeAt(index) || 0); + } + return mismatch === 0; +} + +export async function signPayload(payload: unknown, secret: string) { + const encoded = toBase64Url(encoder.encode(JSON.stringify(payload))); + const signature = toBase64Url(await hmac(encoded, secret)); + return `${encoded}.${signature}`; +} + +export async function verifyPayload(value: string | null, secret: string): Promise { + if (!value) return null; + const [encoded, signature, extra] = value.split("."); + if (!encoded || !signature || extra) return null; + const expected = toBase64Url(await hmac(encoded, secret)); + if (!(await safeSecretEqual(signature, expected))) return null; + try { + return JSON.parse(decoder.decode(fromBase64Url(encoded))) as T; + } catch { + return null; + } +} + +export async function encryptString(value: string, secret: string) { + const key = await aesKey(secret); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoder.encode(value)), + ); + return `v1.${toBase64Url(iv)}.${toBase64Url(ciphertext)}`; +} + +export async function decryptString(value: string, secret: string) { + const [version, encodedIv, encodedCiphertext, extra] = value.split("."); + if (version !== "v1" || !encodedIv || !encodedCiphertext || extra) { + throw new Error("암호화된 값 형식이 올바르지 않습니다."); + } + const key = await aesKey(secret); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromBase64Url(encodedIv) }, + key, + fromBase64Url(encodedCiphertext), + ); + return decoder.decode(plaintext); +} + +function toBase64Url(bytes: Uint8Array) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function fromBase64Url(value: string) { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const binary = atob(padded); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +async function hmac(value: string, secret: string) { + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(`moon-observation:hmac:v1:${secret}`), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(value))); +} + +async function aesKey(secret: string) { + const material = await crypto.subtle.digest( + "SHA-256", + encoder.encode(`moon-observation:aes-gcm:v1:${secret}`), + ); + return crypto.subtle.importKey("raw", material, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); +} diff --git a/gongju-moon-observation/lib/google-drive.ts b/gongju-moon-observation/lib/google-drive.ts new file mode 100644 index 0000000..28665ec --- /dev/null +++ b/gongju-moon-observation/lib/google-drive.ts @@ -0,0 +1,386 @@ +import { decryptString } from "./crypto"; +import { HttpError } from "./http"; +import { getGoogleOAuthConfig, getTokenEncryptionSecret } from "./runtime"; +import type { TeacherRecord } from "./tenants"; + +export const DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file"; +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"; +const DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files"; +const DRIVE_UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3/files"; +const DRIVE_FOLDER_MIME = "application/vnd.google-apps.folder"; + +export interface GoogleTokenResponse { + access_token: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + token_type?: string; + id_token?: string; +} + +export interface GoogleUserInfo { + sub: string; + email: string; + email_verified?: boolean; + name?: string; + picture?: string; +} + +export interface DriveObservationMetadata { + schemaVersion: 1; + studentNumber: number; + studentName: string; + observedAt: string; + memo: string; + status: "visible" | "hidden"; + requestId: string; + createdAt: string; +} + +export interface DriveFile { + id: string; + name?: string; + mimeType?: string; + description?: string; + parents?: string[]; + size?: string; + createdTime?: string; + modifiedTime?: string; + webViewLink?: string; + thumbnailLink?: string; + appProperties?: Record; +} + +export class GoogleApiError extends Error { + constructor( + public readonly status: number, + message: string, + public readonly reason?: string, + ) { + super(message); + } +} + +export function buildGoogleAuthorizationUrl(request: Request, state: string) { + const config = getGoogleOAuthConfig(request); + const url = new URL(GOOGLE_AUTH_URL); + url.searchParams.set("client_id", config.clientId); + url.searchParams.set("redirect_uri", config.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", ["openid", "email", DRIVE_FILE_SCOPE].join(" ")); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + url.searchParams.set("state", state); + return url.toString(); +} + +export async function exchangeAuthorizationCode(request: Request, code: string) { + const config = getGoogleOAuthConfig(request); + const body = new URLSearchParams({ + code, + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: config.redirectUri, + grant_type: "authorization_code", + }); + const tokens = await googleJson(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + assertOnlyDriveFileScope(tokens.scope); + return tokens; +} + +export async function refreshGoogleAccessToken(request: Request, refreshToken: string) { + const config = getGoogleOAuthConfig(request); + const body = new URLSearchParams({ + refresh_token: refreshToken, + client_id: config.clientId, + client_secret: config.clientSecret, + grant_type: "refresh_token", + }); + return googleJson(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); +} + +export async function revokeGoogleToken(token: string) { + const response = await fetch("https://oauth2.googleapis.com/revoke", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token }), + }); + if (!response.ok && response.status !== 400) throw await googleError(response); +} + +export async function getGoogleUserInfo(accessToken: string) { + return googleJson(GOOGLE_USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function getTeacherAccessToken(request: Request, teacher: TeacherRecord) { + let refreshToken: string; + try { + refreshToken = await decryptString(teacher.encryptedRefreshToken, getTokenEncryptionSecret()); + } catch { + throw new HttpError(503, "Google Drive 연결 정보를 읽지 못했습니다. 선생님이 다시 연결해 주세요."); + } + try { + const tokens = await refreshGoogleAccessToken(request, refreshToken); + if (!tokens.access_token) throw new Error("Google access token missing"); + return tokens.access_token; + } catch (error) { + if (error instanceof GoogleApiError && (error.status === 400 || error.status === 401)) { + throw new HttpError(503, "선생님의 Google Drive 연결이 만료되었습니다. 관리 화면에서 다시 연결해 주세요."); + } + throw error; + } +} + +export async function createDriveFolder(accessToken: string, name: string, parentId?: string) { + const url = new URL(DRIVE_FILES_URL); + url.searchParams.set("fields", "id,name,webViewLink"); + const file = await googleJson(url.toString(), { + method: "POST", + headers: driveJsonHeaders(accessToken), + body: JSON.stringify({ + name, + mimeType: DRIVE_FOLDER_MIME, + ...(parentId ? { parents: [parentId] } : {}), + appProperties: { app: "gongju-moon-observation", kind: "folder" }, + }), + }); + if (!file.id) throw new GoogleApiError(502, "Google Drive 폴더 ID를 받지 못했습니다."); + return file; +} + +export async function uploadObservationFile(input: { + accessToken: string; + folderId: string; + classId: string; + fileName: string; + contentType: string; + bytes: Uint8Array; + metadata: DriveObservationMetadata; +}) { + const boundary = `moon_${crypto.randomUUID().replace(/-/g, "")}`; + const driveMetadata = { + name: input.fileName, + parents: [input.folderId], + mimeType: input.contentType, + description: JSON.stringify(input.metadata), + appProperties: { + app: "gongju-moon-observation", + kind: "observation", + classId: input.classId, + requestId: input.metadata.requestId, + status: input.metadata.status, + }, + }; + const binary = input.bytes.buffer.slice( + input.bytes.byteOffset, + input.bytes.byteOffset + input.bytes.byteLength, + ) as ArrayBuffer; + const body = new Blob([ + `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(driveMetadata)}\r\n`, + `--${boundary}\r\nContent-Type: ${input.contentType}\r\n\r\n`, + binary, + `\r\n--${boundary}--`, + ]); + const url = new URL(DRIVE_UPLOAD_URL); + url.searchParams.set("uploadType", "multipart"); + url.searchParams.set( + "fields", + "id,name,mimeType,description,parents,size,createdTime,webViewLink,appProperties", + ); + const file = await googleJson(url.toString(), { + method: "POST", + headers: { + Authorization: `Bearer ${input.accessToken}`, + "Content-Type": `multipart/related; boundary=${boundary}`, + }, + body, + }); + if (!file.id) throw new GoogleApiError(502, "Google Drive 파일 ID를 받지 못했습니다."); + return file; +} + +export async function listObservationFiles(input: { + accessToken: string; + folderId: string; + classId: string; + pageToken?: string | null; + pageSize?: number; + visibleOnly?: boolean; +}) { + const query = [ + `'${escapeDriveQuery(input.folderId)}' in parents`, + "trashed = false", + "appProperties has { key='app' and value='gongju-moon-observation' }", + "appProperties has { key='kind' and value='observation' }", + `appProperties has { key='classId' and value='${escapeDriveQuery(input.classId)}' }`, + ]; + if (input.visibleOnly) { + query.push("appProperties has { key='status' and value='visible' }"); + } + const url = new URL(DRIVE_FILES_URL); + url.searchParams.set("q", query.join(" and ")); + url.searchParams.set("spaces", "drive"); + url.searchParams.set("orderBy", "createdTime desc"); + url.searchParams.set("pageSize", String(Math.min(100, Math.max(1, input.pageSize || 30)))); + url.searchParams.set( + "fields", + "nextPageToken,files(id,name,mimeType,description,parents,size,createdTime,modifiedTime,webViewLink,thumbnailLink,appProperties)", + ); + if (input.pageToken) url.searchParams.set("pageToken", input.pageToken); + return googleJson<{ files?: DriveFile[]; nextPageToken?: string }>(url.toString(), { + headers: { Authorization: `Bearer ${input.accessToken}` }, + }); +} + +export async function getDriveFile(accessToken: string, fileId: string) { + const url = new URL(`${DRIVE_FILES_URL}/${encodeURIComponent(fileId)}`); + url.searchParams.set( + "fields", + "id,name,mimeType,description,parents,size,createdTime,webViewLink,appProperties", + ); + return googleJson(url.toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function downloadDriveFile(accessToken: string, fileId: string) { + const url = new URL(`${DRIVE_FILES_URL}/${encodeURIComponent(fileId)}`); + url.searchParams.set("alt", "media"); + const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); + if (!response.ok) throw await googleError(response); + return response; +} + +export async function updateDriveFile( + accessToken: string, + fileId: string, + patch: Record, +) { + const url = new URL(`${DRIVE_FILES_URL}/${encodeURIComponent(fileId)}`); + url.searchParams.set("fields", "id,name,description,appProperties,webViewLink"); + return googleJson(url.toString(), { + method: "PATCH", + headers: driveJsonHeaders(accessToken), + body: JSON.stringify(patch), + }); +} + +export async function deleteDriveFile(accessToken: string, fileId: string) { + const response = await fetch(`${DRIVE_FILES_URL}/${encodeURIComponent(fileId)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok && response.status !== 404) throw await googleError(response); +} + +export function parseObservationMetadata(file: DriveFile): DriveObservationMetadata | null { + if (!file.description) return null; + try { + const value = JSON.parse(file.description) as Partial; + if ( + value.schemaVersion !== 1 || + !Number.isInteger(value.studentNumber) || + typeof value.studentName !== "string" || + typeof value.observedAt !== "string" || + typeof value.memo !== "string" || + (value.status !== "visible" && value.status !== "hidden") || + typeof value.requestId !== "string" || + typeof value.createdAt !== "string" + ) { + return null; + } + return value as DriveObservationMetadata; + } catch { + return null; + } +} + +export function assertObservationBelongsToClass(file: DriveFile, folderId: string, classId: string) { + if ( + !file.parents?.includes(folderId) || + file.appProperties?.app !== "gongju-moon-observation" || + file.appProperties?.kind !== "observation" || + file.appProperties?.classId !== classId + ) { + throw new HttpError(404, "관찰 기록을 찾을 수 없습니다."); + } +} + +export function googleDriveUserMessage(error: unknown) { + if (!(error instanceof GoogleApiError)) return null; + if (error.reason === "storageQuotaExceeded") { + return "선생님의 Google Drive 저장공간이 부족합니다. 저장공간을 정리한 뒤 다시 시도해 주세요."; + } + if (error.status === 403 || error.status === 429) { + return "Google Drive 요청이 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요."; + } + if (error.status >= 500) { + return "Google Drive가 일시적으로 응답하지 않습니다. 잠시 후 다시 시도해 주세요."; + } + return null; +} + +function assertOnlyDriveFileScope(scope: string | undefined) { + if (!scope) return; + const granted = new Set(scope.split(/\s+/).filter(Boolean)); + if (!granted.has(DRIVE_FILE_SCOPE)) { + throw new GoogleApiError(403, "Google Drive 파일 권한이 승인되지 않았습니다."); + } + const unexpectedDriveScope = [...granted].find( + (value) => value.startsWith("https://www.googleapis.com/auth/drive") && value !== DRIVE_FILE_SCOPE, + ); + if (unexpectedDriveScope) { + throw new GoogleApiError(403, "허용 범위를 넘는 Google Drive 권한이 포함되어 연결을 중단했습니다."); + } +} + +function driveJsonHeaders(accessToken: string) { + return { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json; charset=utf-8", + }; +} + +async function googleJson(url: string, init: RequestInit = {}) { + const response = await fetch(url, init); + if (!response.ok) throw await googleError(response); + return (await response.json()) as T; +} + +async function googleError(response: Response) { + const text = await response.text(); + let message = `Google API 요청에 실패했습니다. (${response.status})`; + let reason: string | undefined; + try { + const payload = JSON.parse(text) as { + error?: string | { message?: string; errors?: Array<{ reason?: string }> }; + error_description?: string; + }; + if (typeof payload.error === "string") { + reason = payload.error; + message = payload.error_description || payload.error; + } else if (payload.error) { + message = payload.error.message || message; + reason = payload.error.errors?.[0]?.reason; + } + } catch { + if (text.trim()) message = text.slice(0, 300); + } + return new GoogleApiError(response.status, message, reason); +} + +function escapeDriveQuery(value: string) { + return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); +} diff --git a/gongju-moon-observation/lib/runtime.ts b/gongju-moon-observation/lib/runtime.ts index 631ab18..da9025d 100644 --- a/gongju-moon-observation/lib/runtime.ts +++ b/gongju-moon-observation/lib/runtime.ts @@ -2,37 +2,59 @@ import { env } from "cloudflare:workers"; export interface AppEnv { DB: D1Database; - BUCKET: R2Bucket; + BUCKET?: R2Bucket; SESSION_SECRET: string; - CLASS_INVITE_TOKEN: string; - ADMIN_PASSWORD_HASH: string; - CLASS_ID?: string; - CLASS_LABEL?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; + TOKEN_ENCRYPTION_KEY?: string; + PUBLIC_ORIGIN?: string; +} + +export interface GoogleOAuthConfig { + clientId: string; + clientSecret: string; + origin: string; + redirectUri: string; } export function getEnv(): AppEnv { const runtime = env as unknown as Partial; - const required = [ - "DB", - "BUCKET", - "SESSION_SECRET", - "CLASS_INVITE_TOKEN", - "ADMIN_PASSWORD_HASH", - ] as const; - - for (const key of required) { - if (!runtime[key]) { - throw new Error(`필수 서버 설정 ${key}가 없습니다.`); - } + if (!runtime.DB) throw new Error("필수 서버 설정 DB가 없습니다."); + if (!runtime.SESSION_SECRET || runtime.SESSION_SECRET.length < 32) { + throw new Error("필수 서버 설정 SESSION_SECRET은 32자 이상이어야 합니다."); } - return runtime as AppEnv; } -export function getClassId(runtime = getEnv()) { - return runtime.CLASS_ID?.trim() || "gongju-4-1"; +export function isGoogleOAuthConfigured(runtime = getEnv()) { + return Boolean(runtime.GOOGLE_CLIENT_ID && runtime.GOOGLE_CLIENT_SECRET); +} + +export function getGoogleOAuthConfig(request: Request, runtime = getEnv()): GoogleOAuthConfig { + if (!runtime.GOOGLE_CLIENT_ID || !runtime.GOOGLE_CLIENT_SECRET) { + throw new Error("Google OAuth 설정이 완료되지 않았습니다."); + } + const requestOrigin = new URL(request.url).origin; + const origin = normalizeOrigin(runtime.PUBLIC_ORIGIN || requestOrigin); + return { + clientId: runtime.GOOGLE_CLIENT_ID, + clientSecret: runtime.GOOGLE_CLIENT_SECRET, + origin, + redirectUri: `${origin}/api/oauth/google/callback`, + }; +} + +export function getTokenEncryptionSecret(runtime = getEnv()) { + const secret = runtime.TOKEN_ENCRYPTION_KEY?.trim() || runtime.SESSION_SECRET; + if (secret.length < 32) throw new Error("토큰 암호화 비밀값은 32자 이상이어야 합니다."); + return secret; +} + +export function getPublicOrigin(request: Request, runtime = getEnv()) { + return normalizeOrigin(runtime.PUBLIC_ORIGIN || new URL(request.url).origin); } -export function getClassLabel(runtime = getEnv()) { - return runtime.CLASS_LABEL?.trim() || "4학년 1반"; +function normalizeOrigin(value: string) { + const url = new URL(value); + return url.origin; } diff --git a/gongju-moon-observation/lib/tenants.ts b/gongju-moon-observation/lib/tenants.ts new file mode 100644 index 0000000..ceb05d6 --- /dev/null +++ b/gongju-moon-observation/lib/tenants.ts @@ -0,0 +1,345 @@ +import { getEnv } from "./runtime"; + +export interface TeacherRecord { + id: string; + googleSub: string; + email: string; + displayName: string; + encryptedRefreshToken: string; + rootFolderId: string; + createdAt: string; + updatedAt: string; +} + +export interface ClassRecord { + id: string; + teacherId: string; + label: string; + inviteTokenHash: string; + encryptedInviteToken: string; + driveFolderId: string; + galleryEnabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface SubmissionReceipt { + requestId: string; + classId: string; + sessionId: string; + driveFileId: string; + status: "visible" | "hidden"; + createdAt: string; +} + +interface TeacherRow { + id: string; + google_sub: string; + email: string; + display_name: string; + encrypted_refresh_token: string; + root_folder_id: string; + created_at: string; + updated_at: string; +} + +interface ClassRow { + id: string; + teacher_id: string; + label: string; + invite_token_hash: string; + encrypted_invite_token: string; + drive_folder_id: string; + gallery_enabled: number; + created_at: string; + updated_at: string; +} + +interface ReceiptRow { + request_id: string; + class_id: string; + session_id: string; + drive_file_id: string; + status: "visible" | "hidden"; + created_at: string; +} + +export async function getTeacherById(id: string) { + const row = await getEnv().DB.prepare( + `SELECT id, google_sub, email, display_name, encrypted_refresh_token, + root_folder_id, created_at, updated_at + FROM teachers WHERE id = ? LIMIT 1`, + ) + .bind(id) + .first(); + return row ? teacherFromRow(row) : null; +} + +export async function getTeacherByGoogleSub(googleSub: string) { + const row = await getEnv().DB.prepare( + `SELECT id, google_sub, email, display_name, encrypted_refresh_token, + root_folder_id, created_at, updated_at + FROM teachers WHERE google_sub = ? LIMIT 1`, + ) + .bind(googleSub) + .first(); + return row ? teacherFromRow(row) : null; +} + +export async function insertTeacher(record: TeacherRecord) { + await getEnv().DB.prepare( + `INSERT INTO teachers + (id, google_sub, email, display_name, encrypted_refresh_token, + root_folder_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + record.id, + record.googleSub, + record.email, + record.displayName, + record.encryptedRefreshToken, + record.rootFolderId, + record.createdAt, + record.updatedAt, + ) + .run(); + return record; +} + +export async function updateTeacher(record: TeacherRecord) { + await getEnv().DB.prepare( + `UPDATE teachers + SET email = ?, display_name = ?, encrypted_refresh_token = ?, + root_folder_id = ?, updated_at = ? + WHERE id = ?`, + ) + .bind( + record.email, + record.displayName, + record.encryptedRefreshToken, + record.rootFolderId, + record.updatedAt, + record.id, + ) + .run(); + return record; +} + +export async function getClassById(id: string) { + const row = await getEnv().DB.prepare( + `SELECT id, teacher_id, label, invite_token_hash, encrypted_invite_token, + drive_folder_id, gallery_enabled, created_at, updated_at + FROM classes WHERE id = ? LIMIT 1`, + ) + .bind(id) + .first(); + return row ? classFromRow(row) : null; +} + +export async function getClassByTeacherId(teacherId: string) { + const row = await getEnv().DB.prepare( + `SELECT id, teacher_id, label, invite_token_hash, encrypted_invite_token, + drive_folder_id, gallery_enabled, created_at, updated_at + FROM classes WHERE teacher_id = ? ORDER BY created_at LIMIT 1`, + ) + .bind(teacherId) + .first(); + return row ? classFromRow(row) : null; +} + +export async function getClassByInviteHash(inviteTokenHash: string) { + const row = await getEnv().DB.prepare( + `SELECT id, teacher_id, label, invite_token_hash, encrypted_invite_token, + drive_folder_id, gallery_enabled, created_at, updated_at + FROM classes WHERE invite_token_hash = ? LIMIT 1`, + ) + .bind(inviteTokenHash) + .first(); + return row ? classFromRow(row) : null; +} + +export async function insertClass(record: ClassRecord) { + await getEnv().DB.prepare( + `INSERT INTO classes + (id, teacher_id, label, invite_token_hash, encrypted_invite_token, + drive_folder_id, gallery_enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + record.id, + record.teacherId, + record.label, + record.inviteTokenHash, + record.encryptedInviteToken, + record.driveFolderId, + record.galleryEnabled ? 1 : 0, + record.createdAt, + record.updatedAt, + ) + .run(); + return record; +} + +export async function updateClass(record: ClassRecord) { + await getEnv().DB.prepare( + `UPDATE classes + SET label = ?, invite_token_hash = ?, encrypted_invite_token = ?, + drive_folder_id = ?, gallery_enabled = ?, updated_at = ? + WHERE id = ? AND teacher_id = ?`, + ) + .bind( + record.label, + record.inviteTokenHash, + record.encryptedInviteToken, + record.driveFolderId, + record.galleryEnabled ? 1 : 0, + record.updatedAt, + record.id, + record.teacherId, + ) + .run(); + return record; +} + +export async function getClassAndTeacher(classId: string) { + const classRecord = await getClassById(classId); + if (!classRecord) return null; + const teacher = await getTeacherById(classRecord.teacherId); + if (!teacher) return null; + return { classRecord, teacher }; +} + +export async function getTeacherWorkspace(teacherId: string) { + const [teacher, classRecord] = await Promise.all([ + getTeacherById(teacherId), + getClassByTeacherId(teacherId), + ]); + if (!teacher || !classRecord) return null; + return { teacher, classRecord }; +} + +export async function deleteTeacherWorkspace(teacherId: string, classId: string) { + const runtime = getEnv(); + await runtime.DB.batch([ + runtime.DB.prepare("DELETE FROM submission_receipts WHERE class_id = ?").bind(classId), + runtime.DB.prepare("DELETE FROM classes WHERE id = ? AND teacher_id = ?").bind(classId, teacherId), + runtime.DB.prepare("DELETE FROM teachers WHERE id = ?").bind(teacherId), + ]); +} + +export async function getReceipt(requestId: string) { + const row = await getEnv().DB.prepare( + `SELECT request_id, class_id, session_id, drive_file_id, status, created_at + FROM submission_receipts WHERE request_id = ? LIMIT 1`, + ) + .bind(requestId) + .first(); + return row ? receiptFromRow(row) : null; +} + +export async function insertReceipt(record: SubmissionReceipt) { + await getEnv().DB.prepare( + `INSERT INTO submission_receipts + (request_id, class_id, session_id, drive_file_id, status, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .bind( + record.requestId, + record.classId, + record.sessionId, + record.driveFileId, + record.status, + record.createdAt, + ) + .run(); + return record; +} + +export async function updateReceiptStatus( + classId: string, + driveFileId: string, + status: "visible" | "hidden", +) { + await getEnv().DB.prepare( + "UPDATE submission_receipts SET status = ? WHERE class_id = ? AND drive_file_id = ?", + ) + .bind(status, classId, driveFileId) + .run(); +} + +export async function deleteReceiptByDriveFileId(classId: string, driveFileId: string) { + await getEnv().DB.prepare( + "DELETE FROM submission_receipts WHERE class_id = ? AND drive_file_id = ?", + ) + .bind(classId, driveFileId) + .run(); +} + +export async function countVisibleReceipts(classId: string) { + const row = await getEnv().DB.prepare( + "SELECT COUNT(*) AS count FROM submission_receipts WHERE class_id = ? AND status = 'visible'", + ) + .bind(classId) + .first<{ count: number }>(); + return Number(row?.count || 0); +} + +export async function countRecentSubmissions(sessionId: string, classId: string) { + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const [deviceCount, classCount] = await Promise.all([ + getEnv().DB.prepare( + "SELECT COUNT(*) AS count FROM submission_receipts WHERE session_id = ? AND created_at >= ?", + ) + .bind(sessionId, tenMinutesAgo) + .first<{ count: number }>(), + getEnv().DB.prepare( + "SELECT COUNT(*) AS count FROM submission_receipts WHERE class_id = ? AND created_at >= ?", + ) + .bind(classId, oneHourAgo) + .first<{ count: number }>(), + ]); + return { + deviceCount: Number(deviceCount?.count || 0), + classCount: Number(classCount?.count || 0), + }; +} + +function teacherFromRow(row: TeacherRow): TeacherRecord { + return { + id: row.id, + googleSub: row.google_sub, + email: row.email, + displayName: row.display_name, + encryptedRefreshToken: row.encrypted_refresh_token, + rootFolderId: row.root_folder_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function classFromRow(row: ClassRow): ClassRecord { + return { + id: row.id, + teacherId: row.teacher_id, + label: row.label, + inviteTokenHash: row.invite_token_hash, + encryptedInviteToken: row.encrypted_invite_token, + driveFolderId: row.drive_folder_id, + galleryEnabled: Boolean(row.gallery_enabled), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function receiptFromRow(row: ReceiptRow): SubmissionReceipt { + return { + requestId: row.request_id, + classId: row.class_id, + sessionId: row.session_id, + driveFileId: row.drive_file_id, + status: row.status, + createdAt: row.created_at, + }; +} diff --git a/gongju-moon-observation/public/student-tenant.js b/gongju-moon-observation/public/student-tenant.js new file mode 100644 index 0000000..a5e0b98 --- /dev/null +++ b/gongju-moon-observation/public/student-tenant.js @@ -0,0 +1,71 @@ +(() => { + 'use strict'; + + let classLabel = '4학년 1반'; + let galleryEnabled = true; + const originalShowView = window.showView; + + if (typeof originalShowView === 'function') { + window.showView = function showTenantView(name) { + if (name === 'gallery' && !galleryEnabled) { + window.showToast?.('선생님이 우리 반 갤러리를 사용하지 않도록 설정했습니다.'); + return; + } + return originalShowView.apply(this, arguments); + }; + } + + document.addEventListener('DOMContentLoaded', () => { + const observer = new MutationObserver(() => { + replaceClassLabels(document.body); + enforceGallerySetting(); + }); + observer.observe(document.body, { subtree: true, childList: true, attributes: true }); + void refreshTenantUi(); + }); + + async function refreshTenantUi() { + try { + const response = await fetch('/api/session', { credentials: 'same-origin' }); + const result = await response.json(); + if (!result.authenticated) return; + if (typeof result.classLabel === 'string' && result.classLabel.trim()) { + classLabel = result.classLabel.trim(); + } + galleryEnabled = result.galleryEnabled !== false; + replaceClassLabels(document.body); + enforceGallerySetting(); + const banner = document.getElementById('sessionBanner'); + if (banner) { + banner.textContent = galleryEnabled + ? `✓ ${classLabel} 수업 참여가 확인되었습니다. 사진 제출과 우리 반 갤러리를 이용할 수 있어요.` + : `✓ ${classLabel} 수업 참여가 확인되었습니다. 사진 제출은 가능하며, 우리 반 갤러리는 선생님이 닫아 두었습니다.`; + } + } catch { + // The original page already handles an unavailable session endpoint. + } + } + + function enforceGallerySetting() { + const button = document.querySelector('[data-view="gallery"]'); + if (!(button instanceof HTMLButtonElement)) return; + if (!galleryEnabled) { + if (!button.disabled) button.disabled = true; + if (button.getAttribute('aria-disabled') !== 'true') button.setAttribute('aria-disabled', 'true'); + if (!button.classList.contains('opacity-45')) button.classList.add('opacity-45'); + if (!button.classList.contains('cursor-not-allowed')) button.classList.add('cursor-not-allowed'); + if (button.textContent !== '전체보기 (닫힘)') button.textContent = '전체보기 (닫힘)'; + } + } + + function replaceClassLabels(root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + if (node.nodeValue && node.nodeValue.includes('4학년 1반')) { + node.nodeValue = node.nodeValue.replaceAll('4학년 1반', classLabel); + } + } + } +})(); diff --git a/gongju-moon-observation/scripts/build-verified.sh b/gongju-moon-observation/scripts/build-verified.sh index 6c69cf3..efc6963 100644 --- a/gongju-moon-observation/scripts/build-verified.sh +++ b/gongju-moon-observation/scripts/build-verified.sh @@ -4,7 +4,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ "${SITES_ENV_READY:-}" != "1" ]]; then - exec "${script_dir}/sites-env.sh" -- "$0" "$@" + exec bash "${script_dir}/sites-env.sh" -- "$0" "$@" fi command -v timeout || { diff --git a/gongju-moon-observation/scripts/configure-cloudflare-deploy.mjs b/gongju-moon-observation/scripts/configure-cloudflare-deploy.mjs new file mode 100644 index 0000000..4c61de5 --- /dev/null +++ b/gongju-moon-observation/scripts/configure-cloudflare-deploy.mjs @@ -0,0 +1,18 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const sourcePath = new URL("../wrangler.cloudflare.jsonc", import.meta.url); +const outputPath = new URL("../wrangler.cloudflare.deploy.json", import.meta.url); +const domain = String(process.env.CLOUDFLARE_CUSTOM_DOMAIN || "").trim().toLowerCase(); + +if (!domain) { + throw new Error("CLOUDFLARE_CUSTOM_DOMAIN이 없습니다. Google OAuth 공개 운영에는 소유한 도메인이 필요합니다."); +} +if (!/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)) { + throw new Error("CLOUDFLARE_CUSTOM_DOMAIN은 프로토콜과 경로를 제외한 호스트명이어야 합니다."); +} + +const config = JSON.parse(await readFile(sourcePath, "utf8")); +config.workers_dev = false; +config.routes = [{ pattern: domain, custom_domain: true }]; +await writeFile(outputPath, `${JSON.stringify(config, null, 2)}\n`); +console.log(`Cloudflare 배포 도메인: https://${domain}`); diff --git a/gongju-moon-observation/scripts/install-ci.sh b/gongju-moon-observation/scripts/install-ci.sh index d02e170..39cfafd 100644 --- a/gongju-moon-observation/scripts/install-ci.sh +++ b/gongju-moon-observation/scripts/install-ci.sh @@ -4,7 +4,7 @@ set -euo pipefail script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ "${SITES_ENV_READY:-}" != "1" ]]; then - exec "${script_dir}/sites-env.sh" -- "$0" "$@" + exec bash "${script_dir}/sites-env.sh" -- "$0" "$@" fi command -v flock || { diff --git a/gongju-moon-observation/scripts/sites-env.sh b/gongju-moon-observation/scripts/sites-env.sh index 7284164..fca3148 100644 --- a/gongju-moon-observation/scripts/sites-env.sh +++ b/gongju-moon-observation/scripts/sites-env.sh @@ -49,4 +49,13 @@ if [[ "$#" -eq 0 ]]; then fi cd "${project_root}" + +# GitHub's contents API may preserve shell files without an executable bit. +# Run such scripts explicitly through bash while leaving normal commands intact. +if [[ "${1}" == *.sh && -f "${1}" && ! -x "${1}" ]]; then + script="${1}" + shift + set -- bash "${script}" "$@" +fi + exec "$@" diff --git a/gongju-moon-observation/tests/cloudflare-deploy.test.mjs b/gongju-moon-observation/tests/cloudflare-deploy.test.mjs new file mode 100644 index 0000000..5099c51 --- /dev/null +++ b/gongju-moon-observation/tests/cloudflare-deploy.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +test("direct Cloudflare build has D1 and no R2 binding", async () => { + const input = JSON.parse(await read("wrangler.cloudflare.jsonc")); + const output = JSON.parse(await read("dist/server/wrangler.json")); + assert.equal(input.name, "gongju-moon-observation"); + assert.deepEqual(input.d1_databases.map((item) => item.binding), ["DB"]); + assert.equal(input.d1_databases[0].migrations_dir, "drizzle-cloudflare"); + assert.equal(input.r2_buckets, undefined); + assert.equal(output.main, "index.js"); + assert.equal(output.assets.directory, "../client"); + assert.deepEqual(output.d1_databases.map((item) => item.binding), ["DB"]); + assert.equal(output.r2_buckets?.length || 0, 0); +}); + +test("fresh Cloudflare migration cannot store student PII", async () => { + const migration = await read("drizzle-cloudflare/0000_central_drive_oauth.sql"); + assert.match(migration, /CREATE TABLE `teachers`/); + assert.match(migration, /CREATE TABLE `classes`/); + assert.match(migration, /CREATE TABLE `submission_receipts`/); + assert.doesNotMatch( + migration, + /student_name|student_number|observed_at|memo|image_key|image_bytes|CREATE TABLE `observations`/, + ); +}); + +test("production config generator requires an owned custom domain", async () => { + const generator = await read("scripts/configure-cloudflare-deploy.mjs"); + assert.match(generator, /CLOUDFLARE_CUSTOM_DOMAIN/); + assert.match(generator, /custom_domain: true/); + assert.match(generator, /config\.workers_dev = false/); +}); diff --git a/gongju-moon-observation/tests/rendered-html.test.mjs b/gongju-moon-observation/tests/rendered-html.test.mjs index eb50667..2d51c59 100644 --- a/gongju-moon-observation/tests/rendered-html.test.mjs +++ b/gongju-moon-observation/tests/rendered-html.test.mjs @@ -2,50 +2,81 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -test("builds the moon observation app without external runtime CSS", async () => { - const html = await readFile(new URL("../dist/client/index.html", import.meta.url), "utf8"); - const css = await readFile(new URL("../dist/client/app.css", import.meta.url), "utf8"); +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +test("builds the existing student app without external runtime CSS", async () => { + const html = await read("dist/client/index.html"); + const css = await read("dist/client/app.css"); + const tenant = await read("dist/client/student-tenant.js"); assert.match(html, /공주 달 관찰 탐험대<\/title>/); assert.match(html, /href="\/app\.css"/); assert.ok(css.length > 20_000, "compiled Tailwind CSS should be present"); + assert.match(tenant, /result\.classLabel/); + assert.match(tenant, /result\.galleryEnabled/); assert.doesNotMatch(html, /cdn\.tailwindcss\.com|fonts\.googleapis\.com|google\.script\.run/); }); -test("keeps restricted routes behind server-side session checks", async () => { - const observations = await readFile(new URL("../app/api/observations/route.ts", import.meta.url), "utf8"); - const images = await readFile(new URL("../app/api/images/[id]/route.ts", import.meta.url), "utf8"); - const worker = await readFile(new URL("../worker/index.ts", import.meta.url), "utf8"); - assert.match(observations, /getStudentSession\(request\)/); - assert.match(observations, /throw new HttpError\(401/); - assert.match(images, /getViewerSession|getAdminSession/); - assert.match(images, /status !== "visible"/); - assert.match(worker, /X-Robots-Tag/); - assert.match(worker, /Referrer-Policy/); - assert.match(worker, /Content-Security-Policy/); -}); - -test("uses Blob multipart upload and lazy gallery loading", async () => { - const source = await readFile(new URL("../public/index.html", import.meta.url), "utf8"); +test("keeps Blob upload, class sessions, and protected Drive image routes", async () => { + const source = await read("public/index.html"); + const observations = await read("app/api/observations/route.ts"); + const images = await read("app/api/images/[id]/route.ts"); + const worker = await read("worker/index.ts"); assert.match(source, /canvas\.toBlob\(/); assert.match(source, /new FormData\(\)/); assert.match(source, /formData\.append\('photo', compressedImageBlob/); - assert.match(source, /imageUrl/); assert.doesNotMatch(source, /toDataURL\(|imageData|google\.script\.run/); assert.doesNotMatch(source, /renderCalendar\(\);\s*refreshGallery\(\);/); + assert.match(observations, /getStudentSession\(request\)/); + assert.match(images, /getTeacherSession/); + assert.match(images, /getStudentSession/); + assert.match(images, /parseObservationMetadata/); + assert.match(worker, /student-tenant\.js/); + assert.match(worker, /Content-Security-Policy/); }); -test("keeps student QR sessions available for the full observation period", async () => { - const auth = await readFile(new URL("../lib/auth.ts", import.meta.url), "utf8"); - const admin = await readFile(new URL("../app/admin/page.tsx", import.meta.url), "utf8"); +test("requests only drive.file among Google Drive scopes", async () => { + const drive = await read("lib/google-drive.ts"); + const auth = await read("lib/auth.ts"); + assert.match(drive, /https:\/\/www\.googleapis\.com\/auth\/drive\.file/); + const requestedScope = drive.match(/url\.searchParams\.set\("scope", ([^\n]+)\);/)?.[1]; + assert.equal(requestedScope, '["openid", "email", DRIVE_FILE_SCOPE].join(" ")'); + assert.equal((drive.match(/https:\/\/www\.googleapis\.com\/auth\/drive\.file/g) || []).length, 1); + assert.match(drive, /unexpectedDriveScope/); + assert.match(drive, /value !== DRIVE_FILE_SCOPE/); + assert.match(drive, /access_type", "offline"/); + assert.match(drive, /prompt", "consent"/); assert.match(auth, /STUDENT_SESSION_MAX_AGE = 60 \* 24 \* 60 \* 60/); - assert.match(admin, /60일 동안 제출과 갤러리를 이용/); + assert.match(auth, /moon_teacher_session/); + assert.match(auth, /moon_google_oauth_state/); +}); + +test("stores student content in Drive and only non-PII receipts in central D1", async () => { + const route = await read("app/api/observations/route.ts"); + const drive = await read("lib/google-drive.ts"); + const migration = await read("drizzle/0001_central_drive_oauth.sql"); + assert.match(route, /uploadObservationFile/); + assert.match(route, /insertReceipt/); + assert.doesNotMatch(route, /BUCKET\.put|runtime\.BUCKET|INSERT INTO observations/); + assert.match(drive, /description: JSON\.stringify\(input\.metadata\)/); + const receiptsTable = migration.slice(migration.indexOf("CREATE TABLE `submission_receipts`")); + assert.match(receiptsTable, /`request_id`/); + assert.match(receiptsTable, /`drive_file_id`/); + assert.doesNotMatch(receiptsTable, /student_name|student_number|observed_at|memo|image_bytes/); }); -test("ships D1 and R2 bindings with a migration", async () => { - const hosting = JSON.parse(await readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8")); - const migration = await readFile(new URL("../drizzle/0000_glossy_nomad.sql", import.meta.url), "utf8"); +test("supports teacher onboarding, Drive management, and complete disconnect", async () => { + const callback = await read("app/api/oauth/google/callback/route.ts"); + const admin = await read("app/admin/page.tsx"); + const disconnect = await read("app/api/admin/disconnect/route.ts"); + const privacy = await read("app/privacy/page.tsx"); + const hosting = JSON.parse(await read(".openai/hosting.json")); assert.equal(hosting.d1, "DB"); - assert.equal(hosting.r2, "BUCKET"); - assert.match(migration, /CREATE TABLE `observations`/); - assert.match(migration, /UNIQUE INDEX `observations_request_id_unique`/); + assert.match(callback, /encryptedRefreshToken/); + assert.match(callback, /createDriveFolder/); + assert.match(admin, /Google Drive 연결하기/); + assert.match(admin, /새 QR 만들기/); + assert.match(admin, /연결 해제/); + assert.match(disconnect, /deleteTeacherWorkspace/); + assert.match(disconnect, /revokeGoogleToken/); + assert.match(privacy, /학생 사진·이름·번호·관찰 시각·메모/); }); diff --git a/gongju-moon-observation/vite.config.ts b/gongju-moon-observation/vite.config.ts index d8c8054..f3a4a31 100644 --- a/gongju-moon-observation/vite.config.ts +++ b/gongju-moon-observation/vite.config.ts @@ -42,6 +42,9 @@ export default defineConfig(async () => { // Wrangler snapshots its log path while the Cloudflare plugin is imported. const { cloudflare } = await import("@cloudflare/vite-plugin"); + const directCloudflareBuild = Boolean( + process.env.CLOUDFLARE_VITE_WRANGLER_CONFIG_PATH, + ); return { server: { @@ -53,11 +56,11 @@ export default defineConfig(async () => { }, plugins: [ vinext(), - sites(), + ...(directCloudflareBuild ? [] : [sites()]), cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }, inspectorPort: false, - config: localBindingConfig, + ...(directCloudflareBuild ? {} : { config: localBindingConfig }), }), ], }; diff --git a/gongju-moon-observation/worker/index.ts b/gongju-moon-observation/worker/index.ts index 23d39af..0cc8688 100644 --- a/gongju-moon-observation/worker/index.ts +++ b/gongju-moon-observation/worker/index.ts @@ -2,10 +2,11 @@ import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization"; import handler from "vinext/server/app-router-entry"; +const LEGACY_SITE_ORIGIN = "https://classroom-webapp-2026.znr1.chatgpt.site"; + interface Env { ASSETS: Fetcher; DB: D1Database; - BUCKET: R2Bucket; IMAGES: { input(stream: ReadableStream): { transform(options: Record<string, unknown>): { @@ -20,12 +21,6 @@ interface ExecutionContext { passThroughOnException(): void; } -// Image security config. SVG sources with .svg extension auto-skip the -// optimization endpoint on the client side (served directly, no proxy). -// To route SVGs through the optimizer (with security headers), set -// dangerouslyAllowSVG: true in next.config.js and uncomment below: -// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true }; - const worker = { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { const url = new URL(request.url); @@ -40,8 +35,16 @@ const worker = { return result.response(); }, }, allowedWidths); - } else if (url.pathname === "/") { - response = await env.ASSETS.fetch(new Request(new URL("/index.html", request.url))); + } else if (url.pathname === "/" || url.pathname === "/index.html") { + const asset = await env.ASSETS.fetch(new Request(new URL("/index.html", request.url))); + const html = (await asset.text()).replaceAll(LEGACY_SITE_ORIGIN, url.origin); + const body = html.includes("/student-tenant.js") + ? html + : html.replace("</body>", "<script src=\"/student-tenant.js\" defer></script></body>"); + const headers = new Headers(asset.headers); + headers.delete("Content-Length"); + headers.set("Content-Type", "text/html; charset=utf-8"); + response = new Response(body, { status: asset.status, statusText: asset.statusText, headers }); } else { response = await handler.fetch(request, env, ctx); } diff --git a/gongju-moon-observation/wrangler.cloudflare.jsonc b/gongju-moon-observation/wrangler.cloudflare.jsonc new file mode 100644 index 0000000..6258b82 --- /dev/null +++ b/gongju-moon-observation/wrangler.cloudflare.jsonc @@ -0,0 +1,17 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "gongju-moon-observation", + "main": "./worker/index.ts", + "compatibility_date": "2026-08-24", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + "d1_databases": [ + { + "binding": "DB", + "migrations_dir": "drizzle-cloudflare" + } + ], + "observability": { + "enabled": true + } +}