diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..48bc274 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,2 @@ +GOOGLE_CLIENT_ID=replace-with-linku-google-client-id +GOOGLE_CLIENT_SECRET=replace-with-linku-google-client-secret diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml new file mode 100644 index 0000000..bd08727 --- /dev/null +++ b/.github/workflows/deploy-cloudflare.yml @@ -0,0 +1,45 @@ +name: Deploy Cloudflare Worker + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-cloudflare-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate + run: | + pnpm run test:template-share + pnpm run test:worker + pnpm run build:local + pnpm run build:gh-pages + pnpm run build:worker + pnpm run lint + + - name: Deploy Worker + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: pnpm exec wrangler deploy diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 1999f07..a701a33 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -2,8 +2,6 @@ name: PR Build Check on: pull_request: - branches: - - main jobs: build: @@ -37,5 +35,10 @@ jobs: - name: Build GitHub Pages share viewer run: pnpm run build:gh-pages + - name: Test and build Cloudflare Worker + run: | + pnpm run test:worker + pnpm run build:worker + - name: Build success run: echo "✅ Build completed successfully!" diff --git a/.gitignore b/.gitignore index 3a5aea3..254e2c0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ gh-pages *.sw? .claude +.wrangler +.dev.vars* +!.dev.vars.example diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3feefbf..799128d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -56,16 +56,23 @@ IndexedDB로 복사합니다. 이전 값은 한 릴리즈 동안 rollback 원본 `.linku.json` 파일로 내보냅니다. Pages에서 확장 프로그램으로 가져오는 외부 메시지는 manifest와 background 양쪽에서 LinKU share 경로로 제한합니다. -계정 로그인, 여러 기기 동기화, 충돌 처리와 cloud share는 이 로컬 저장소 위에 -별도 계층으로 추가하며, 로컬 저장 성공 여부와 분리해야 합니다. 상세 경계는 -`docs/LOCAL_FIRST.md`를 참고합니다. +Google 로그인 후에는 IndexedDB v2의 outbox가 템플릿 변경을 Cloudflare Worker와 +R2에 동기화합니다. 로컬 저장 성공 여부와 원격 동기화 상태는 분리하며, ETag 충돌은 +로컬본을 충돌 복사본으로 보존합니다. 상세 경계와 운영 방법은 +`docs/LOCAL_FIRST.md`, `docs/SERVERLESS.md`를 참고합니다. ### Backend와 인증 -`src/apis/client.ts`가 `VITE_API_BASE_URL`을 기준으로 backend 요청, bearer token, -response parsing, 만료 감지를 중앙 처리합니다. Google OAuth는 -`src/background/handlers/oauth.ts`에서 `chrome.identity.launchWebAuthFlow`를 -사용하며 token은 `chrome.storage.local`에 저장합니다. +기존 feature API는 `src/apis/client.ts`가 `VITE_API_BASE_URL`을 기준으로 처리합니다. +계정 동기화는 `src/utils/accountSync.ts`가 `https://linku.turtlehwan.dev/api`와 직접 +통신합니다. Google OAuth 시작은 `src/background/handlers/oauth.ts`에서 +`chrome.identity.launchWebAuthFlow`를 사용합니다. access token은 +`chrome.storage.session`, refresh token과 profile은 `chrome.storage.local`에 +저장합니다. + +검증된 모든 Google 계정은 같은 계정 기능을 사용합니다. KU 이메일 인증, +guest/member 승격, 이메일 도메인 제한은 없으며 계정 key는 Google `sub`에서 만든 +opaque ID입니다. feature API는 이 client와 background 경계를 재사용해야 합니다. auth code, token, authorization header를 로그에 남기지 않습니다. @@ -101,7 +108,8 @@ popup이 닫힌 동안 background polling은 실행하지 않습니다. - `chrome.storage.local`: auth, 설정, todo, badge, 공지 캐시, 시간표 metadata와 snapshot/override. -- IndexedDB `linku`: 개인 template, draft, 사용자 icon blob. +- IndexedDB `linku`: 개인 template, draft, 사용자 icon blob, sync outbox와 계정별 + ETag metadata. - `localStorage`: non-extension 시간표 fallback과 이전 template/draft의 1회 마이그레이션 원본. 새 template 데이터는 쓰지 않습니다. - IndexedDB: 사용자가 직접 올린 시간표 이미지 blob. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index fdd7439..a520206 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -20,6 +20,9 @@ pnpm run build:local backend 기능에는 유효한 `VITE_API_BASE_URL`이 필요합니다. 실제 secret은 commit하지 마세요. +Cloudflare 계정 동기화는 `docs/SERVERLESS.md`를 따릅니다. 로컬 Worker secret은 +`.dev.vars`에만 두고, Google client secret을 Vite 환경 변수로 노출하지 마세요. + ## 작업 원칙 - 하나의 branch와 PR은 하나의 목적에 집중합니다. @@ -57,6 +60,8 @@ TypeScript, React hook, shared utility를 수정했다면 lint를 실행합니 pnpm run lint pnpm run test:timetable pnpm run test:template-share +pnpm run test:worker +pnpm run build:worker ``` 변경 유형별 추가 확인: diff --git a/docs/GA4-Data-Taxonomy.md b/docs/GA4-Data-Taxonomy.md index 8016f40..6a47277 100644 --- a/docs/GA4-Data-Taxonomy.md +++ b/docs/GA4-Data-Taxonomy.md @@ -14,7 +14,7 @@ | 핵심 가치 행동 파악 | 어떤 행동이 LinKU의 핵심 가치를 보여주는가 | link click rate, template apply rate, alert/todo usage | | 기능 채택 파악 | 사용자가 어떤 기능을 실제로 쓰는가 | feature adoption by domain | | 템플릿 기능 성과 파악 | 템플릿 생성/저장/동기화/게시 흐름이 잘 작동하는가 | editor conversion funnel | -| 계정 연동 파악 | 로그인/이메일 인증이 사용성에 어떤 영향을 주는가 | login start -> success, guest -> verified | +| 계정 연동 파악 | Google 로그인이 사용성에 어떤 영향을 주는가 | login start -> success | ## Principles @@ -68,7 +68,6 @@ | `error_code` | string | 실패 코드 | `network_error`, `auth_required` | | `error_message` | string | 사람이 읽는 에러 설명 | `sync_failed` | | `is_logged_in` | boolean | 로그인 상태 | `true` | -| `is_guest` | boolean | 게스트 회원 여부 | `false` | > **구현 상태 표기** > - `구현됨` — `analytics.ts`에 헬퍼 함수가 존재하고 call site에 연결됨 @@ -103,11 +102,9 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | Event Name | 상태 | 목적 | 주요 Params | 우선순위 | | --- | --- | --- | --- | --- | | `auth_login_start` | 구현됨 | 로그인 의도 파악 | `provider`, `ui_location` | P1 | -| `auth_login_success` | 구현됨 | 실제 로그인 성공률 측정 | `provider`, `is_guest` | P1 | +| `auth_login_success` | 구현됨 | 실제 로그인 성공률 측정 | `provider` | P1 | | `auth_login_fail` | 구현됨 | 로그인 장애 파악 | `provider`, `error_code`, `error_message` | P1 | | `auth_logout` | 구현됨 | 로그아웃 행동 파악 | `ui_location` | P2 | -| `auth_email_verification_start` | 구현됨 | 게스트 → 회원 전환 시작점 | `ui_location` | P1 | -| `auth_email_verification_success` | 구현됨 | 회원 전환 완료 | `domain_type` | P1 | ## Template Events @@ -182,7 +179,6 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | --- | --- | --- | | `search_submit` | `MP_search_submit` | MP_ prefix 적용 | | `auth_login_start/success/fail` | `MP_authLogin_start/success/fail` | prefix + camelCase trio | -| `auth_email_verification_start/success` | `MP_authEmailVerification_start/success` | prefix + camelCase | | `auth_logout` | `MP_auth_logout` | prefix 적용 | | `settings_open` | `MP_settings_open` | prefix 적용 | | `settings_credentials_saved/deleted` | `MP_settingsCredentials_save/delete` | prefix + camelCase, 이벤트명은 동작형 save/delete 사용 | @@ -223,7 +219,6 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | P0 | `template_apply` | 구현됨 | | P1 | `auth_login_start` | 구현됨 | | P1 | `auth_login_success` | 구현됨 | -| P1 | `auth_email_verification_success` | 구현됨 | | P1 | `template_editor_open` | 구현됨 | | P1 | `template_item_add` | 구현됨 | | P1 | `system_error` | 구현됨 | @@ -236,7 +231,7 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | Session-based return cohort | `extension_session_start` | | Core action retention | `extension_first_open` cohort + `link_open` return condition | | Template funnel | `template_editor_open` → `template_item_add` → `template_save_success` → `template_sync_success` → `template_publish_success` | -| Auth funnel | `auth_login_start` → `auth_login_success` → `auth_email_verification_success` | +| Auth funnel | `auth_login_start` → `auth_login_success` | ## Non-Goals @@ -275,11 +270,9 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | `MP_alerts_view` | 공지 탭 진입 | 공지 탭 사용 여부 | `view_mode`, `category` | `Alerts.tsx · initialize()` | - | | `MP_alertsItem_open` | 공지 클릭 | 공지 클릭률 측정 | `alert_id`, `category`, `source` | `AlertItem.tsx · handleClick` | - | | `MP_alertsSubscription_update` | 구독 변경 | 학과 구독 변경 파악 | `category`, `subscription_result`(`subscribe`\|`unsubscribe`) | `SubscriptionManager.tsx · handleSubscribe`, `handleUnsubscribe` | - | -| `MP_authEmailVerification_start` | 이메일 인증 시작 | 게스트→회원 전환 시작점 | `ui_location` | `EmailVerificationDialog.tsx · useEffect([open])` | 다이얼로그 재진입마다 전송 → funnel 시작 수 과집계 가능 | -| `MP_authEmailVerification_success` | 이메일 인증 완료 | 회원 전환 완료 | `domain_type` | `EmailVerificationDialog.tsx · handleVerifyCode` | - | | `MP_authLogin_fail` | 로그인 실패 | 로그인 장애 파악 | `provider`, `error_code`, `error_message` | `SettingsDialog.tsx · handleGoogleLogin` (결과·예외 분기) | - | | `MP_authLogin_start` | 로그인 시도 | 로그인 의도 파악 | `provider`, `ui_location` | `SettingsDialog.tsx · handleGoogleLogin` | - | -| `MP_authLogin_success` | 로그인 성공 | 실제 로그인 성공률 측정 | `provider`, `is_guest` | `SettingsDialog.tsx · handleGoogleLogin` | - | +| `MP_authLogin_success` | 로그인 성공 | 실제 로그인 성공률 측정 | `provider` | `SettingsDialog.tsx · handleGoogleLogin` | Google 인증 성공 시 모든 계정 기능 사용 가능 | | `MP_auth_logout` | 로그아웃 | 로그아웃 행동 파악 | `ui_location` | `SettingsDialog.tsx · handleLogout` | - | | `MP_banner_open` | 배너 클릭 | 배너 클릭 효율 측정 | `banner_id`, `banner_title`, `banner_position` | `ImageCarousel.tsx · Image onClick` | - | | `MP_labsFeature_use` | Labs 기능 사용 | Labs 기능 사용 측정 | `feature_name`, `result?` | `QRGeneratorSection.tsx · regenerate()`, `LibrarySeatSection.tsx · handleOpenRoom` | ServerClockSection 미연결 | diff --git a/docs/LOCAL_FIRST.md b/docs/LOCAL_FIRST.md index 68e3cc1..3ee5778 100644 --- a/docs/LOCAL_FIRST.md +++ b/docs/LOCAL_FIRST.md @@ -38,9 +38,9 @@ LinKU의 개인화 기능은 서버가 없어도 먼저 동작하고, 계정 기 - Pages에서 보낸 가져오기 요청은 service worker가 `chrome.storage.local` queue에 보관하고, popup이 열릴 때 검증 후 IndexedDB에 저장합니다. -## 후속 stateful 계층의 계약 +## Stateful 계층의 계약 -계정 동기화 PR은 다음 원칙을 지켜 이 기반 위에 추가합니다. +계정 동기화 계층은 다음 원칙을 지켜 이 기반 위에서 동작합니다. 1. IndexedDB 저장은 항상 먼저 완료하고 성공 UI를 반환합니다. 2. 동기화는 durable outbox로 별도 수행하며 네트워크 실패가 로컬 저장을 rollback하지 @@ -52,5 +52,5 @@ LinKU의 개인화 기능은 서버가 없어도 먼저 동작하고, 계정 기 5. Worker는 인증, 사용자별 object namespace, optimistic concurrency와 공유 수명만 담당합니다. 템플릿 편집·검증·압축·미리보기는 프론트에 둡니다. -stateful 계층이 추가되기 전에는 로그인, 여러 기기 동기화, cloud share, 커뮤니티 -게시를 제공한다고 표시하지 않습니다. +로그인과 여러 기기 템플릿 동기화, 큰 payload의 30일 cloud share만 제공합니다. +커뮤니티 게시·검색·좋아요는 아직 제공하지 않습니다. diff --git a/docs/SERVERLESS.md b/docs/SERVERLESS.md new file mode 100644 index 0000000..2fb32d3 --- /dev/null +++ b/docs/SERVERLESS.md @@ -0,0 +1,159 @@ +# Cloudflare 계정 동기화 운영 + +LinKU의 편집과 로컬 저장은 Cloudflare 없이 동작합니다. 이 계층은 Google 계정으로 +로그인한 사용자가 템플릿을 여러 Chrome 기기에서 동기화하고, URL fragment에 담기지 +않는 큰 템플릿을 제한된 기간 공유할 때만 사용합니다. + +## 제품 경계 + +| 기능 | 실행/저장 위치 | Worker 장애 시 | +| --- | --- | --- | +| 템플릿 편집·검증·압축 | extension | 정상 | +| 개인 템플릿·draft·아이콘 | IndexedDB | 정상 | +| 작은 공유 | GitHub Pages URL fragment | 정상 | +| Google OAuth code 교환 | Worker | 새 로그인 불가 | +| 계정 세션 | R2 private object | 기존 로컬 기능 정상 | +| 템플릿 동기화 | IndexedDB outbox → Worker → R2 | outbox에 보존 | +| 큰 공유 | Worker → R2 public object | `.linku.json` 파일로 대체 | + +D1, KV, Durable Objects, Queue, 상시 실행 서버는 사용하지 않습니다. 게시·검색·좋아요와 +같은 커뮤니티 기능은 다중 사용자 index와 moderation 정책이 필요하므로 이 PR의 +cloud share와 같은 기능으로 취급하지 않습니다. + +## 데이터 흐름 + +```mermaid +flowchart LR + UI["Popup / editor"] --> IDB["IndexedDB v2"] + IDB --> Outbox["template outbox"] + UI --> Fragment["gzip URL fragment"] + UI -->|"로그인 시 명시적 sync"| Worker["Cloudflare Worker"] + Outbox --> Worker + Worker --> R2["R2 Standard"] + Pages["GitHub Pages viewer"] --> Fragment + Pages -->|"큰 공유만"| Worker +``` + +- 저장은 IndexedDB transaction이 먼저 완료합니다. 원격 실패가 로컬 저장을 + rollback하지 않습니다. +- 템플릿마다 UUID와 R2 ETag를 사용합니다. 새 object는 `If-None-Match: *`, 기존 + object는 `If-Match`로 저장합니다. +- 충돌하면 로컬 변경을 새 UUID의 `(충돌 복사본)`으로 남기고 원격본을 원래 위치에 + 반영합니다. +- 삭제 중 원격 변경이 확인되면 최신 원격본을 `(삭제 충돌 복사본)`으로 먼저 남긴 + 뒤 삭제를 재시도합니다. +- 삭제는 30일 tombstone으로 전달합니다. 30일 넘게 offline인 기기의 복구 정책은 + event log가 필요한 단계에서 다시 설계합니다. +- outbox는 항목별로 실패 횟수와 오류를 기록합니다. 한 항목 실패가 다음 항목의 + 동기화를 막지 않습니다. +- 서로 다른 Google 계정의 원격 metadata는 섞지 않습니다. 같은 Chrome profile에서 + 계정을 바꾸려면 기존 계정 데이터 삭제를 명시적으로 거쳐야 합니다. + +## 인증 + +Google OAuth의 `openid email profile`만 요청합니다. Worker는 Google JWKS로 ID token +서명과 issuer, audience, expiry, nonce, `email_verified`를 검증합니다. Google +이메일 도메인은 제한하지 않으며 KU 이메일 인증 단계는 없습니다. + +Google `sub` 원문은 저장하지 않습니다. issuer와 함께 SHA-256한 opaque account ID만 +R2 key에 사용합니다. 자체 JWT 대신 256-bit 임의 access/refresh token을 발급하고, +R2 session object에는 token secret의 SHA-256 hash만 저장합니다. + +- access token: 15분, `chrome.storage.session` +- refresh token: 30일, extension context로 제한한 `chrome.storage.local` +- 동시 device session: 계정당 최대 5개 +- refresh 시 access/refresh token 모두 회전 +- logout/계정 삭제: R2 session object 제거로 즉시 무효화 + +필요한 Worker secret은 다음 두 개뿐입니다. + +```text +GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET +``` + +확장 프로그램의 계정 API 주소는 `https://linku.turtlehwan.dev/api`로 고정해 기존 +백엔드용 `VITE_API_BASE_URL`과 분리합니다. 따라서 계정 동기화를 위해 추가하는 +프론트엔드 환경 변수는 없습니다. + +Google OAuth client에는 LinKU 전용 Web application 자격 증명을 만들고 다음 redirect +URI를 정확히 등록합니다. + +```text +https://linku.turtlehwan.dev/api/auth/google/callback +``` + +## R2 구조와 제한 + +```text +auth/oauth-states/{sha256(state)}.json +auth/exchanges/{sha256(code)}.json +auth/sessions/{account-id}/{device-id}.json +private/{account-id}/templates/{template-uuid}.json +private/{account-id}/share-index/{share-id}.json +public/shares/{share-id}.json +``` + +application quota는 계정당 템플릿 50개, session 5개, 활성 cloud share 20개입니다. +payload는 256KB 이하이고 cloud share는 30일 후 만료됩니다. R2 lifecycle rule도 +다음 prefix에 설정해 요청이 없어도 임시 object가 정리되게 합니다. + +- `auth/oauth-states/`: 1일 +- `auth/exchanges/`: 1일 +- `public/shares/`: 31일 + +## 최초 설정 + +R2 Standard subscription을 활성화한 뒤 bucket을 만듭니다. R2는 무료 사용량이 +포함된 usage-based subscription이므로, “무료 한도 안에서 운영”은 가능하지만 코드가 +초과 과금을 원천 차단해 주지는 않습니다. + +```bash +pnpm exec wrangler r2 bucket create linku-data +pnpm exec wrangler r2 bucket create linku-data-preview +pnpm exec wrangler secret put GOOGLE_CLIENT_ID +pnpm exec wrangler secret put GOOGLE_CLIENT_SECRET +``` + +로컬 Worker 검증에서는 커밋하지 않는 `.dev.vars`를 사용합니다. + +```bash +cp .dev.vars.example .dev.vars +pnpm exec wrangler dev +``` + +`wrangler.jsonc`의 custom domain을 사용하려면 `turtlehwan.dev` zone이 같은 Cloudflare +account에 있어야 합니다. CI 배포에는 repository secret으로 다음 두 값을 둡니다. + +- `CLOUDFLARE_API_TOKEN`: Worker 배포와 R2 binding에 필요한 최소 권한 +- `CLOUDFLARE_ACCOUNT_ID`: 대상 account ID + +## 무료 한도와 운영 경보 + +2026-08-12 기준 공식 한도는 Workers Free 100,000 requests/day, 10ms CPU/request이며, +R2 Standard 무료 구간은 10GB-month, Class A 1M/month, Class B 10M/month입니다. +현재 Worker dry-run bundle은 gzip 약 9KB이고 CPU 제한을 10ms로 고정했습니다. + +Cloudflare Rate Limiting binding은 인증, 공개 공유, 계정 요청을 나눠 적용하지만 +location-local이며 정확한 과금 차단 장치가 아닙니다. Cloudflare Billing budget +alert를 낮게 설정하고 Workers/R2 사용량을 확인해야 합니다. + +- [Workers limits](https://developers.cloudflare.com/workers/platform/limits/) +- [R2 pricing](https://developers.cloudflare.com/r2/pricing/) +- [R2 get started](https://developers.cloudflare.com/r2/get-started/) +- [R2 conditional operations](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/) + +## 검증과 배포 + +```bash +pnpm run test:worker +pnpm run build:local +pnpm run build:gh-pages +pnpm run build:worker +pnpm run lint +``` + +`build:worker`는 `wrangler deploy --dry-run`이라 외부 상태를 바꾸지 않습니다. 실제 +배포는 `Deploy Cloudflare Worker` workflow를 수동 실행합니다. 배포 후에는 health, +Google 계정 선택, 두 Chrome profile 간 템플릿 왕복, offline 편집 재시도, 충돌 +복사본, cloud share 만료, 계정 데이터 삭제를 실제 extension runtime에서 확인합니다. diff --git a/package.json b/package.json index 450253e..bf59e56 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,14 @@ "watch": "node scripts/updateVersion.js && tsc -b && vite build --watch --mode production", "watch:local": "tsc -b && vite build --watch --mode development", "build:gh-pages": "tsc -b && vite build --mode gh-pages", + "build:worker": "tsc -p worker/tsconfig.json && wrangler deploy --dry-run", "check:content-scripts": "node scripts/checkContentScripts.js", "lint": "eslint .", "test:timetable": "node --experimental-strip-types --test tests/timetable/*.test.ts", "test:feedback": "node --experimental-strip-types --test tests/feedback/*.test.ts", "test:forms": "node --experimental-strip-types --test tests/forms/*.test.ts", "test:template-share": "node --experimental-strip-types --test tests/templates/*.test.ts", + "test:worker": "node --experimental-strip-types --test tests/worker/*.test.ts", "test:todo": "node --experimental-strip-types --test tests/todo/*.test.ts", "preview": "vite preview", "deploy": "git subtree push --prefix gh-pages origin gh-pages" @@ -51,6 +53,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@cloudflare/workers-types": "^5.20260812.1", "@eslint/js": "^10.0.1", "@tailwindcss/postcss": "^4.3.3", "@tailwindcss/vite": "^4.3.3", @@ -70,7 +73,8 @@ "typescript": "~6.0.3", "typescript-eslint": "^8.65.0", "vite": "^8.2.0", - "vite-plugin-svgr": "^5.2.0" + "vite-plugin-svgr": "^5.2.0", + "wrangler": "^4.121.0" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a237fa..9992a4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@cloudflare/workers-types': + specifier: ^5.20260812.1 + version: 5.20260812.1 '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) @@ -94,7 +97,7 @@ importers: version: 4.3.3 '@tailwindcss/vite': specifier: ^4.3.3 - version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) '@types/chrome': specifier: ^0.2.5 version: 0.2.5 @@ -112,7 +115,7 @@ importers: version: 19.2.4(@types/react@19.2.18) '@vitejs/plugin-react-swc': specifier: ^4.3.3 - version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)) + version: 4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) autoprefixer: specifier: ^10.5.4 version: 10.5.4(postcss@8.5.25) @@ -142,10 +145,13 @@ importers: version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.2.0 - version: 8.2.0(@types/node@24.13.3)(jiti@2.7.0) + version: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) vite-plugin-svgr: specifier: ^5.2.0 - version: 5.2.0(typescript@6.0.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)) + version: 5.2.0(typescript@6.0.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)) + wrangler: + specifier: ^4.121.0 + version: 4.121.0(@cloudflare/workers-types@5.20260812.1) packages: @@ -241,6 +247,56 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260804.1': + resolution: {integrity: sha512-191/PPEFicRK2wK69eXzSjnLgHL79k7zR2VUpyIr8rhFgGns1b5bTHgnBuUrgU2LPbEtwbE5eL2hJ0uhLAFEow==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260804.1': + resolution: {integrity: sha512-aI2cAFLsrNkSz3kLSQrgrO9ICTfY7jb2h0jgaWDE9mQLQQDfpXeYrKWt07tTgMmeNP79o9w3NZe3aqtt8mTGHQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260804.1': + resolution: {integrity: sha512-KBCjxBIlN2jucfQGaTK4EgmPsWzQgYR/zYhPfi3mkWwdoTyG1dgrt2aizKps/SYse85ci/SOxojKk0/K7vstPw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260804.1': + resolution: {integrity: sha512-7lswfarBZ7xkHpTFrNb74ExwOltIalVaASc+GOYfCNtnwFxK/JZSVByfm/hnZEBtrHU7fMY2z7dYT64rwS0pHg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260804.1': + resolution: {integrity: sha512-GOgRWYtxISN5rAWfx3K7zubt3xEn0/ZPFrbcLL+GwT4ouCKyNoHqfT1cPNB6Nx7t8BHEjnuTG7gkHO4Wd5ORdg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260812.1': + resolution: {integrity: sha512-eG2aQdUWH8RYEubpBjUE98L2G57JYzGEcI8PgROTjt9YjunbEnPBHP4371Usj8v8pTWcXtbOmI+LRCXF4KhJgQ==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -257,6 +313,165 @@ packages: peerDependencies: react: '>=16.8.0' + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -327,6 +542,168 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -343,9 +720,21 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -831,6 +1220,13 @@ packages: rollup: optional: true + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -1236,6 +1632,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -1289,6 +1688,10 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -1369,6 +1772,14 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1580,6 +1991,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1757,6 +2172,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + miniflare@5.20260804.1-alpha: + resolution: {integrity: sha512-J0QBHEj+d75TyFE9VhH+2dXgvdYt/pfyDlm+9IiYUFocQvqYy9iuW1DIqNawh1N7Kr6iMrDzVkgDSdt6pA75uA==} + engines: {node: '>=22.0.0'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -1823,10 +2242,16 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1940,9 +2365,18 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1972,6 +2406,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} @@ -2017,6 +2455,13 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2106,10 +2551,37 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + workerd@1.20260804.1: + resolution: {integrity: sha512-b0P38g5/ssemwWxd/mafNYggEZ0ere7PCiUH6RCHkgqjRhhXTP45nDiL2L3iIvi8uF2IjNrGpVgMXEunCaeY/w==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.121.0: + resolution: {integrity: sha512-dcARWk6CyaD0vJBSLjJn4K2yo+mYko73hzryq+t/9DXnyAq877RWIMl7uOMcDKs5dDXdoickNhZTKxBYT9XKsA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260804.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -2128,6 +2600,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -2260,6 +2738,35 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260804.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260804.1 + + '@cloudflare/workerd-darwin-64@1.20260804.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260804.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260804.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260804.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260804.1': + optional: true + + '@cloudflare/workers-types@5.20260812.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': dependencies: react: 19.2.8 @@ -2278,6 +2785,89 @@ snapshots: react: 19.2.8 tslib: 2.8.1 + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.8.0(jiti@2.7.0))': dependencies: eslint: 10.8.0(jiti@2.7.0) @@ -2340,6 +2930,112 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2359,8 +3055,25 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@oxc-project/types@0.142.0': {} + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@radix-ui/number@1.1.3': {} '@radix-ui/primitive@1.1.7': {} @@ -2782,6 +3495,10 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.5 + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -2981,12 +3698,12 @@ snapshots: postcss: 8.5.25 tailwindcss: 4.3.3 - '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) '@types/chrome@0.2.5': dependencies: @@ -3118,11 +3835,11 @@ snapshots: '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react-swc@4.3.3(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0))': + '@vitejs/plugin-react-swc@4.3.3(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 '@swc/core': 1.15.47 - vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) transitivePeerDependencies: - '@swc/helpers' @@ -3164,6 +3881,8 @@ snapshots: baseline-browser-mapping@2.11.12: {} + blake3-wasm@2.1.5: {} + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -3218,6 +3937,8 @@ snapshots: cookie-es@3.1.1: {} + cookie@1.1.1: {} + cosmiconfig@8.3.6(typescript@6.0.3): dependencies: import-fresh: 3.3.1 @@ -3285,6 +4006,37 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -3477,6 +4229,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -3606,6 +4360,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + miniflare@5.20260804.1-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260804.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.4: dependencies: brace-expansion: 5.0.9 @@ -3669,8 +4435,12 @@ snapshots: path-key@3.1.1: {} + path-to-regexp@6.3.0: {} + path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -3772,8 +4542,42 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + set-blocking@2.0.0: {} + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3802,6 +4606,8 @@ snapshots: dependencies: ansi-regex: 5.0.1 + supports-color@10.2.2: {} + svg-parser@2.0.4: {} tailwind-merge@3.6.0: {} @@ -3840,6 +4646,12 @@ snapshots: undici-types@7.18.2: {} + undici@7.29.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: browserslist: 4.28.7 @@ -3865,18 +4677,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 - vite-plugin-svgr@5.2.0(typescript@6.0.3)(vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0)): + vite-plugin-svgr@5.2.0(typescript@6.0.3)(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)): dependencies: '@rollup/pluginutils': 5.3.0 '@svgr/core': 8.1.0(typescript@6.0.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@6.0.3)) - vite: 8.2.0(@types/node@24.13.3)(jiti@2.7.0) + vite: 8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0) transitivePeerDependencies: - rollup - supports-color - typescript - vite@8.2.0(@types/node@24.13.3)(jiti@2.7.0): + vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -3885,6 +4697,7 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.13.3 + esbuild: 0.28.1 fsevents: 2.3.3 jiti: 2.7.0 @@ -3896,12 +4709,39 @@ snapshots: word-wrap@1.2.5: {} + workerd@1.20260804.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260804.1 + '@cloudflare/workerd-darwin-arm64': 1.20260804.1 + '@cloudflare/workerd-linux-64': 1.20260804.1 + '@cloudflare/workerd-linux-arm64': 1.20260804.1 + '@cloudflare/workerd-windows-64': 1.20260804.1 + + wrangler@4.121.0(@cloudflare/workers-types@5.20260812.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260804.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260804.1-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260804.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260812.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.21.0: {} + y18n@4.0.3: {} yallist@3.1.1: {} @@ -3927,6 +4767,19 @@ snapshots: yocto-queue@0.1.0: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zod-validation-error@4.0.2(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/public/manifest.json b/public/manifest.json index e35ad46..1952ca2 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -39,7 +39,8 @@ "*://ecampus.konkuk.ac.kr/*", "*://library.konkuk.ac.kr/*", "https://www.google-analytics.com/*", - "https://ku-linku.store/*" + "https://ku-linku.store/*", + "https://linku.turtlehwan.dev/*" ], "externally_connectable": { "matches": [ diff --git a/src/apis/auth.ts b/src/apis/auth.ts deleted file mode 100644 index f83a68d..0000000 --- a/src/apis/auth.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Auth API - * Authentication and verification operations - */ - -import { post, ENDPOINTS } from './client'; -import type { - ApiResponse, - GoogleOAuthRequest, - GoogleOAuthResponse, - SendCodeRequest, - SendCodeResponse, - VerifyCodeRequest, - VerifyCodeResponse, -} from '../types/api'; - -/** - * Google OAuth authorization - * Login or get guest token through Google OAuth2 - */ -export async function googleOAuth( - data: GoogleOAuthRequest -): Promise> { - return post(ENDPOINTS.AUTH.GOOGLE_OAUTH, data); -} - -/** - * Send verification code to email - * POST /auth/send-code - * @param data.kuMail - 건국대 이메일 (@konkuk.ac.kr) - * @requires Authorization: Bearer {guest_token} - */ -export async function sendVerificationCode( - data: SendCodeRequest -): Promise> { - return post(ENDPOINTS.AUTH.SEND_CODE, data); -} - -/** - * Verify email code - * POST /auth/verify-code - * @param data.kuMail - 건국대 이메일 - * @param data.authCode - 6자리 인증 코드 - * @requires Authorization: Bearer {guest_token} - */ -export async function verifyEmailCode( - data: VerifyCodeRequest -): Promise> { - return post(ENDPOINTS.AUTH.VERIFY_CODE, data); -} diff --git a/src/apis/client.ts b/src/apis/client.ts index c6eebbd..e9df33c 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -32,8 +32,6 @@ export const ENDPOINTS = { // Auth AUTH: { GOOGLE_OAUTH: "/oauth2/authorization/google", - SEND_CODE: "/auth/send-code", - VERIFY_CODE: "/auth/verify-code", }, // Templates @@ -88,7 +86,6 @@ async function clearAccessToken(): Promise { await removeStorage([ "accessToken", "refreshToken", - "guestToken", ]); } diff --git a/src/apis/index.ts b/src/apis/index.ts index 3762781..94a3b99 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -7,7 +7,6 @@ export * from "./client"; // Domain APIs -export * from "./auth"; export * from "./templates"; export * from "./icons"; export * from "./posted-templates"; diff --git a/src/background/handlers/oauth.ts b/src/background/handlers/oauth.ts index 48e0bbc..bca5c5e 100644 --- a/src/background/handlers/oauth.ts +++ b/src/background/handlers/oauth.ts @@ -1,257 +1,196 @@ -/** - * Google OAuth Handler for Chrome Extension - * - * 백엔드 API 스펙 (PR #26): - * 1. GET /api/oauth2/google?redirectUri={uri} - Google OAuth 페이지로 리다이렉트 - * 2. GET /api/oauth2/google/login?redirectUri={uri}&code={code} - 토큰 교환 - * - * 응답 형식: - * { - * "code": 1000, - * "message": "SUCCESS", - * "result": { - * "accessToken": "token_here", - * "refreshToken": "refresh_or_null" - * } - * } - */ - -import type { GoogleLoginResponse } from "../types"; +import type { GoogleLoginResponse, SilentReauthResponse } from "../types"; +import type { ApiResult, AuthSessionResponse } from "@/types/serverless"; import { - debugLog, - errorLog, - getErrorLogDetails, - getHttpErrorLogDetails, - warnLog, -} from "@/utils/logger"; + ACCOUNT_ACCESS_TOKEN_EXPIRES_AT_KEY, + ACCOUNT_ACCESS_TOKEN_KEY, + ACCOUNT_REFRESH_TOKEN_KEY, + clearStoredAuth, + saveAuthSession, +} from "@/utils/authStorage"; +import { debugLog, errorLog, getErrorLogDetails } from "@/utils/logger"; + +export const ACCOUNT_API_BASE_URL = "https://linku.turtlehwan.dev/api"; + +let refreshPromise: Promise | null = null; + +function base64Url(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/gu, "-") + .replace(/\//gu, "_") + .replace(/=+$/u, ""); +} + +function randomVerifier(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return base64Url(bytes); +} + +async function challengeFor(verifier: string): Promise { + const bytes = new TextEncoder().encode(verifier); + return base64Url( + new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)), + ); +} -// Backend URL from environment -const BACKEND_URL = (() => { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - if (!baseUrl) return ""; +async function getDeviceId(): Promise { + const stored = await chrome.storage.local.get("linkuDeviceId"); + if (typeof stored.linkuDeviceId === "string") return stored.linkuDeviceId; + const deviceId = crypto.randomUUID(); + await chrome.storage.local.set({ linkuDeviceId: deviceId }); + return deviceId; +} +async function readSessionResult( + response: Response, +): Promise> { try { - const url = new URL(baseUrl); - if (url.pathname.endsWith("/api")) { - url.pathname = url.pathname.slice(0, -4); - } - const result = url.origin + url.pathname; - return result.endsWith("/") ? result.slice(0, -1) : result; + return (await response.json()) as ApiResult; } catch { - const result = baseUrl.replace("/api", ""); - return result.endsWith("/") ? result.slice(0, -1) : result; - } -})(); - -/** - * Save tokens and auth state to chrome.storage.local - */ -async function saveTokens( - accessToken: string, - refreshToken?: string | null -): Promise { - const isGuest = !refreshToken; - const data: Record = { - accessToken, - isGuest, - }; - if (refreshToken) { - data.refreshToken = refreshToken; + return { + ok: false, + error: { + code: "UPSTREAM_UNAVAILABLE", + message: "계정 서버 응답을 읽지 못했습니다.", + retryable: true, + requestId: "unavailable", + }, + }; } - await chrome.storage.local.set(data); } -/** - * Handle Google OAuth Login - * Uses chrome.identity.launchWebAuthFlow for OAuth flow - */ export async function handleGoogleLogin(): Promise { try { - debugLog("[Background] Starting Google OAuth flow"); - - // 1. Get extension ID and construct redirect URI - const extensionId = chrome.runtime.id; - const redirectUri = `https://${extensionId}.chromiumapp.org/`; - - if (!BACKEND_URL) { - return { - success: false, - error: "Backend URL이 설정되지 않았습니다. 환경 변수를 확인해주세요.", - }; - } + const redirectUri = chrome.identity.getRedirectURL(); + const verifier = randomVerifier(); + const state = randomVerifier(); + const authUrl = new URL(`${ACCOUNT_API_BASE_URL}/auth/google/start`); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("code_challenge", await challengeFor(verifier)); + authUrl.searchParams.set("state", state); - // 2. Construct OAuth URL (새 API 스펙) - const authUrl = new URL(`${BACKEND_URL}/api/oauth2/google`); - authUrl.searchParams.set("redirectUri", redirectUri); - - // 3. Launch OAuth flow using chrome.identity API const responseUrl = await chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true, }); + if (!responseUrl) return { success: false, error: "인증이 취소되었습니다." }; - if (!responseUrl) { - return { success: false, error: "인증이 취소되었습니다." }; - } - - // 4. Parse response URL to extract code - const url = new URL(responseUrl); - const code = url.searchParams.get("code"); - const error = url.searchParams.get("error"); - - debugLog("[Background] Extracted code:", code ? "있음" : "없음"); - - if (error) { - warnLog("[Background] OAuth error returned from provider", { error }); + const callback = new URL(responseUrl); + const providerError = callback.searchParams.get("error"); + const code = callback.searchParams.get("code"); + if (providerError) { return { success: false, - error: `OAuth 오류: ${error}`, + error: + providerError === "access_denied" + ? "사용자가 인증을 취소했습니다." + : "Google 로그인을 완료하지 못했습니다.", }; } - - if (!code) { - return { - success: false, - error: "인증 코드를 받지 못했습니다.", - }; + if (!code || callback.searchParams.get("state") !== state) { + return { success: false, error: "인증 응답을 확인하지 못했습니다." }; } - // 5. Exchange code for token via backend (새 API 스펙) - debugLog("[Background] Exchanging code for token..."); - - const tokenUrl = new URL(`${BACKEND_URL}/api/oauth2/google/login`); - tokenUrl.searchParams.set("redirectUri", redirectUri); - tokenUrl.searchParams.set("code", code); - - const tokenResponse = await fetch(tokenUrl.toString(), { - method: "GET", - headers: { - Accept: "application/json", - }, + const response = await fetch(`${ACCOUNT_API_BASE_URL}/auth/exchange`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code, + verifier, + deviceId: await getDeviceId(), + }), }); - - debugLog("[Background] Token Response Status:", tokenResponse.status); - - if (!tokenResponse.ok) { - const errorBody = await tokenResponse.text(); - errorLog( - "[Background] Token exchange failed", - getHttpErrorLogDetails( - tokenResponse.status, - tokenResponse.statusText, - errorBody, - ), - ); - return { - success: false, - error: `토큰 교환 실패: ${tokenResponse.status} ${tokenResponse.statusText}`, - }; - } - - const tokenData = await tokenResponse.json(); - - // 6. Parse backend response - // 응답 형식: { code: 1000, message: "SUCCESS", result: { accessToken, refreshToken } } - if (tokenData.code !== 1000) { - warnLog("[Background] Backend rejected token exchange", { - status: tokenResponse.status, - code: tokenData.code, - message: tokenData.message, - }); - return { - success: false, - error: tokenData.message || "토큰 교환에 실패했습니다.", - }; + const result = await readSessionResult(response); + if (!result.ok) return { success: false, error: result.error.message }; + await saveAuthSession(result.data); + debugLog("[Background] Google session saved"); + return { success: true, profile: result.data.profile }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message.includes("did not approve") || + message.includes("closed") || + message.includes("cancelled") || + message.includes("interrupted") + ) { + return { success: false, error: "사용자가 인증을 취소했습니다." }; } + errorLog("[Background] OAuth failed", getErrorLogDetails(error)); + return { success: false, error: "로그인을 완료하지 못했습니다." }; + } +} - const { accessToken, refreshToken } = tokenData.result || {}; - - if (!accessToken) { - errorLog("[Background] No accessToken in OAuth response", { - status: tokenResponse.status, - code: tokenData.code, - }); - return { - success: false, - error: "백엔드 응답에서 토큰을 찾을 수 없습니다.", - }; +async function performRefresh(): Promise { + const stored = await chrome.storage.local.get(ACCOUNT_REFRESH_TOKEN_KEY); + const refreshToken = stored[ACCOUNT_REFRESH_TOKEN_KEY]; + if (typeof refreshToken !== "string") return false; + try { + const response = await fetch(`${ACCOUNT_API_BASE_URL}/auth/refresh`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + const result = await readSessionResult(response); + if (!result.ok) { + if (!result.error.retryable) await clearStoredAuth(); + return false; } + await saveAuthSession(result.data); + return true; + } catch { + return false; + } +} - // 7. Save tokens - await saveTokens(accessToken, refreshToken); - debugLog("[Background] Tokens saved successfully"); +export async function refreshSession(): Promise { + if (!refreshPromise) { + refreshPromise = performRefresh().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} - // 8. Return success response - // refreshToken이 없으면 게스트(신규 회원) - const isGuest = !refreshToken; +export async function handleSilentReauth(): Promise { + const success = await refreshSession(); + return success + ? { success: true } + : { success: false, error: "세션이 만료되었습니다. 다시 로그인해주세요." }; +} - return { - success: true, - response: { - guestToken: accessToken, - requiresSignup: isGuest, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), - profile: { - email: "", - name: "", - picture: "", - }, - }, - }; - } catch (error) { - // 사용자 취소 케이스는 warn으로, 실제 오류는 error로 구분 - const isUserCancellation = - error instanceof Error && - (error.message.includes("The user did not approve") || - error.message.includes("closed") || - error.message.includes("cancelled") || - error.message.includes("interrupted")); +export async function getValidAccessToken(): Promise { + const stored = await chrome.storage.session.get([ + ACCOUNT_ACCESS_TOKEN_KEY, + ACCOUNT_ACCESS_TOKEN_EXPIRES_AT_KEY, + ]); + const accessToken = stored[ACCOUNT_ACCESS_TOKEN_KEY]; + const expiresAt = stored[ACCOUNT_ACCESS_TOKEN_EXPIRES_AT_KEY]; + if ( + typeof accessToken === "string" && + typeof expiresAt === "string" && + Date.parse(expiresAt) > Date.now() + 30_000 + ) { + return accessToken; + } + if (!(await refreshSession())) return null; + const refreshed = await chrome.storage.session.get(ACCOUNT_ACCESS_TOKEN_KEY); + const refreshedToken = refreshed[ACCOUNT_ACCESS_TOKEN_KEY]; + return typeof refreshedToken === "string" ? refreshedToken : null; +} - if (isUserCancellation) { - debugLog("[Background] OAuth cancelled by user", { - message: error instanceof Error ? error.message : String(error), +export async function logoutSession(): Promise { + const token = await getValidAccessToken(); + if (token) { + try { + await fetch(`${ACCOUNT_API_BASE_URL}/auth/logout`, { + method: "POST", + headers: { authorization: `Bearer ${token}` }, }); - } else { - errorLog("[Background] OAuth error", getErrorLogDetails(error)); + } catch { + // Local logout remains available during a Worker outage. } - - // User closed the popup or cancelled - if (error instanceof Error) { - if ( - error.message.includes("The user did not approve") || - error.message.includes("closed") || - error.message.includes("cancelled") - ) { - return { - success: false, - error: "사용자가 인증을 취소했습니다.", - }; - } - - // Authorization page could not be loaded - if (error.message.includes("Authorization page could not be loaded")) { - return { - success: false, - error: - "인증 페이지를 로드할 수 없습니다. 백엔드 서버 상태를 확인해주세요.", - }; - } - - // Interrupted - if (error.message.includes("interrupted")) { - return { - success: false, - error: "로그인이 중단되었습니다.", - }; - } - } - - return { - success: false, - error: - error instanceof Error - ? error.message - : "알 수 없는 오류가 발생했습니다.", - }; } + await clearStoredAuth(); } diff --git a/src/background/index.ts b/src/background/index.ts index 963af80..221647d 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -21,7 +21,6 @@ import type { SilentReauthResponse, TimetableImportResponse, } from "./types"; -import { handleGoogleLogin } from "./handlers/oauth"; import { formatTodoBadgeCount, TODO_BADGE_BACKGROUND_COLOR, @@ -35,14 +34,20 @@ import { import type { TemplateSharePayloadV1 } from "@/types/templateShare"; import { enqueuePendingTemplateImport } from "@/utils/pendingTemplateImports"; import { validateTemplateSharePayload } from "@/utils/templateShareCodec"; +import { + handleGoogleLogin, + handleSilentReauth, + logoutSession, +} from "./handlers/oauth"; debugLog("[Background] Service worker initialized"); async function restrictLocalStorageAccess(): Promise { try { - await chrome.storage.local.setAccessLevel({ - accessLevel: "TRUSTED_CONTEXTS", - }); + await Promise.all([ + chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }), + chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" }), + ]); } catch (error) { warnLog( "[Background] Failed to restrict local storage access", @@ -76,12 +81,20 @@ chrome.runtime.onMessage.addListener( const typedMessage = message as BackgroundMessage; debugLog("[Background] Message received:", typedMessage.type); - // Handle Google Login + if (typedMessage.type === BackgroundMessageType.LOGOUT) { + void logoutSession() + .then(() => sendResponse({ success: true })) + .catch(() => + sendResponse({ success: false, error: "로그아웃에 실패했습니다." }), + ); + return true; + } + if (isGoogleLoginMessage(typedMessage)) { debugLog("[Background] Handling Google login request"); // Handle async OAuth flow - handleGoogleLogin() + void handleGoogleLogin() .then((response: GoogleLoginResponse) => { debugLog("[Background] Sending OAuth response to popup"); sendResponse(response); @@ -111,20 +124,8 @@ chrome.runtime.onMessage.addListener( ); // Reuse Google OAuth flow for silent reauth - handleGoogleLogin() - .then((response: GoogleLoginResponse) => { - debugLog( - "[Background] Silent reauth completed:", - response.success, - ); - const reauthResponse: SilentReauthResponse = { - success: response.success, - error: response.success - ? undefined - : (response as { error?: string }).error, - }; - sendResponse(reauthResponse); - }) + void handleSilentReauth() + .then((response: SilentReauthResponse) => sendResponse(response)) .catch((error: unknown) => { warnLog( "[Background] Silent reauth error", diff --git a/src/background/types.ts b/src/background/types.ts index e5e0cf9..a06c2ac 100644 --- a/src/background/types.ts +++ b/src/background/types.ts @@ -3,7 +3,7 @@ * Type definitions for communication between popup and background script */ -import type { GoogleOAuthResponse } from '../types/api'; +import type { AuthProfile } from '@/types/serverless'; import type { TimetableImportMode, TimetableImportResponse, @@ -15,6 +15,7 @@ import type { export enum BackgroundMessageType { GOOGLE_LOGIN = 'GOOGLE_LOGIN', SILENT_REAUTH = 'SILENT_REAUTH', + LOGOUT = 'LOGOUT', TIMETABLE_IMPORT = 'TIMETABLE_IMPORT', } @@ -38,7 +39,7 @@ export interface GoogleLoginMessage extends BackgroundMessage { */ export interface GoogleLoginSuccessResponse { success: true; - response: GoogleOAuthResponse; + profile: AuthProfile; } /** diff --git a/src/components/Editor/TemplatePreview/TemplateCard.tsx b/src/components/Editor/TemplatePreview/TemplateCard.tsx index d66c947..b552fe4 100644 --- a/src/components/Editor/TemplatePreview/TemplateCard.tsx +++ b/src/components/Editor/TemplatePreview/TemplateCard.tsx @@ -1,7 +1,16 @@ import type { TemplateSummary } from '@/types/api'; import { cn } from '@/lib/utils'; import { TemplatePreviewCanvas } from './TemplatePreviewCanvas'; -import { Check, HardDrive, Loader2, Share2, Trash2 } from 'lucide-react'; +import { + Check, + Cloud, + CloudOff, + HardDrive, + Loader2, + RefreshCw, + Share2, + Trash2, +} from 'lucide-react'; interface TemplateCardProps { template: TemplateSummary; @@ -15,6 +24,35 @@ interface TemplateCardProps { isActionLoading?: boolean; } +const SyncStatus = ({ status }: { status: TemplateSummary['syncStatus'] }) => { + if (status === 'synced') { + return ( +
+ +
+ ); + } + if (status === 'pending') { + return ( +
+ +
+ ); + } + if (status === 'error') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); +}; + export const TemplateCard = ({ template, onClick, @@ -70,12 +108,7 @@ export const TemplateCard = ({ )} {template.templateId !== 0 && ( -
- -
+ )} {onApply && (isSelected ? ( diff --git a/src/components/EmailVerificationDialog.tsx b/src/components/EmailVerificationDialog.tsx deleted file mode 100644 index d06f67c..0000000 --- a/src/components/EmailVerificationDialog.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Email Verification Dialog - * 건국대 이메일 인증 다이얼로그 - */ - -import { useState, useEffect } from 'react'; -import { toast } from 'sonner'; -import { Mail, ArrowLeft, Loader2 } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { sendVerificationCode, verifyEmailCode } from '@/apis/auth'; -import { - authCodeSchema, - getFirstValidationMessage, - konkukEmailSchema, -} from '@/utils/formValidation'; -import { errorLog } from '@/utils/logger'; -import { sendAuthEmailVerificationStart, sendAuthEmailVerificationSuccess } from '@/utils/analytics'; -import { setStorage } from '@/utils/chrome'; - -interface EmailVerificationDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onVerificationComplete: () => void; -} - -type Step = 'email' | 'code'; - -const EMAIL_DOMAIN = '@konkuk.ac.kr'; - -export function EmailVerificationDialog({ - open, - onOpenChange, - onVerificationComplete, -}: EmailVerificationDialogProps) { - const [step, setStep] = useState('email'); - const [emailId, setEmailId] = useState(''); // ID part only (before @) - const [authCode, setAuthCode] = useState(''); - const [isLoading, setIsLoading] = useState(false); - - // 다이얼로그가 열릴 때 인증 시작 이벤트 전송 - useEffect(() => { - if (open) sendAuthEmailVerificationStart('settings_dialog'); - }, [open]); - - // Full email address - const kuMail = emailId ? `${emailId}${EMAIL_DOMAIN}` : ''; - - const handleSendCode = async () => { - // Validate ID part - if (!emailId.trim()) { - toast.error('이메일 아이디를 입력해주세요.'); - return; - } - - const validation = konkukEmailSchema.safeParse(kuMail); - if (!validation.success) { - toast.error(getFirstValidationMessage(validation.error)); - return; - } - - setIsLoading(true); - try { - const response = await sendVerificationCode({ kuMail: validation.data }); - - if (response.success) { - setEmailId(validation.data.slice(0, -EMAIL_DOMAIN.length)); - toast.success('인증 코드가 발송되었습니다. 이메일을 확인해주세요.'); - setStep('code'); - } else { - // Handle specific error codes - const errorCode = response.error?.code; - if (errorCode === '1005') { - toast.error('올바른 건국대 이메일을 입력해주세요.'); - } else if (errorCode === '5014') { - toast.error('이미 등록된 이메일입니다.'); - } else { - toast.error(response.error?.message || '인증 코드 발송에 실패했습니다.'); - } - } - } catch (error) { - errorLog('Failed to send verification code:', error); - toast.error('인증 코드 발송에 실패했습니다. 다시 시도해주세요.'); - } finally { - setIsLoading(false); - } - }; - - const handleVerifyCode = async () => { - const validation = authCodeSchema.safeParse(authCode); - if (!validation.success) { - toast.error(getFirstValidationMessage(validation.error)); - return; - } - - setIsLoading(true); - try { - const response = await verifyEmailCode({ - kuMail, - authCode: validation.data, - }); - - if (response.success) { - toast.success('이메일 인증이 완료되었습니다!'); - // Store verified email - await setStorage({ kuMail }); - sendAuthEmailVerificationSuccess('konkuk.ac.kr'); - // Trigger re-login to get member token - onVerificationComplete(); - handleClose(); - } else { - const errorCode = response.error?.code; - if (errorCode === '5015') { - toast.error('인증 코드가 올바르지 않습니다. 다시 확인해주세요.'); - } else { - toast.error(response.error?.message || '인증에 실패했습니다.'); - } - } - } catch (error) { - errorLog('Failed to verify code:', error); - toast.error('인증에 실패했습니다. 다시 시도해주세요.'); - } finally { - setIsLoading(false); - } - }; - - const handleResendCode = async () => { - setIsLoading(true); - try { - const response = await sendVerificationCode({ kuMail }); - if (response.success) { - toast.success('인증 코드가 재발송되었습니다.'); - } else { - toast.error(response.error?.message || '재발송에 실패했습니다.'); - } - } catch (error) { - errorLog('Failed to resend code:', error); - toast.error('재발송에 실패했습니다.'); - } finally { - setIsLoading(false); - } - }; - - const handleClose = () => { - setStep('email'); - setEmailId(''); - setAuthCode(''); - onOpenChange(false); - }; - - const handleBack = () => { - setStep('email'); - setAuthCode(''); - }; - - return ( - - - - - {step === 'code' && ( - - )} - - 건국대 이메일 인증 - - - {step === 'email' - ? '건국대학교 이메일로 인증을 진행해주세요.' - : `${kuMail}로 발송된 6자리 인증 코드를 입력해주세요.`} - - - -
- {step === 'email' ? ( -
- -
- { - // Remove @ and everything after, allow only valid email ID characters - const value = e.target.value.replace(/@.*$/, '').toLowerCase(); - setEmailId(value); - }} - disabled={isLoading} - onKeyDown={(e) => e.key === 'Enter' && handleSendCode()} - className="rounded-r-none border-r-0" - /> - - {EMAIL_DOMAIN} - -
-

- 건국대학교 이메일 아이디를 입력해주세요. -

-
- ) : ( -
-
- - { - // Only allow digits, max 6 - const value = e.target.value.replace(/\D/g, '').slice(0, 6); - setAuthCode(value); - }} - disabled={isLoading} - maxLength={6} - onKeyDown={(e) => e.key === 'Enter' && handleVerifyCode()} - className="text-center text-lg tracking-widest" - /> -
- -
- )} -
- - - - {step === 'email' ? ( - - ) : ( - - )} - -
-
- ); -} diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 5ed6e9c..7d3b010 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -25,21 +25,23 @@ import { logout, isLoggedIn, getUserProfile, - isGuestUser, UserProfile, } from "@/utils/oauth"; +import { deleteCloudAccount, syncAccount } from "@/utils/accountSync"; +import { + clearAccountSyncState, + SyncAccountMismatchError, +} from "@/storage/linkuDb"; import { Info, Palette, LogOut, - Mail, Settings as SettingsIcon, Timer, User, } from "lucide-react"; import { toast } from "sonner"; import { getChromeApi, getStorage, setStorage } from "@/utils/chrome"; -import { EmailVerificationDialog } from "@/components/EmailVerificationDialog"; import TodoDeadlineBadge from "@/components/Tabs/TodoList/TodoDeadlineBadge"; import { calculateDDay } from "@/utils/todo/dateFormat"; import { @@ -265,11 +267,9 @@ const ECampusCredential = () => { const GoogleOAuthSection = () => { const [loggedIn, setLoggedIn] = useState(false); - const [isGuest, setIsGuest] = useState(false); const [userProfile, setUserProfile] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [showEmailVerification, setShowEmailVerification] = useState(false); - const [verifiedEmail, setVerifiedEmail] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); // Check login status on mount useEffect(() => { @@ -280,14 +280,11 @@ const GoogleOAuthSection = () => { useEffect(() => { const handleLogout = () => { setLoggedIn(false); - setIsGuest(false); setUserProfile(null); - setVerifiedEmail(null); }; const handleUnauthorized = () => { setLoggedIn(false); - setIsGuest(false); setUserProfile(null); }; @@ -305,17 +302,8 @@ const GoogleOAuthSection = () => { setLoggedIn(loggedIn); if (loggedIn) { - const guest = await isGuestUser(); - setIsGuest(guest); - const profile = await getUserProfile(); setUserProfile(profile); - - // Load verified email if exists - const kuMail = await getStorage('kuMail'); - if (kuMail) { - setVerifiedEmail(kuMail); - } } }; @@ -328,19 +316,30 @@ const GoogleOAuthSection = () => { if (result.success) { setLoggedIn(true); - - // Check if this is a guest (requires signup) - if (result.response.requiresSignup) { - setIsGuest(true); - sendAuthLoginSuccess("google", true); - // Auto-open email verification dialog for guests - setShowEmailVerification(true); - toast.info("건국대 이메일 인증이 필요합니다."); - } else { - setIsGuest(false); - setUserProfile(result.response.profile); - sendAuthLoginSuccess("google", false); - toast.success("로그인 성공!"); + setUserProfile(result.profile); + sendAuthLoginSuccess("google"); + try { + const syncResult = await syncAccount(); + toast.success("로그인 성공!", { + description: + syncResult.failed === 0 + ? "이 기기의 템플릿을 계정과 동기화했습니다." + : "로그인은 완료됐고, 일부 변경은 다음 동기화 때 다시 시도합니다.", + }); + } catch (syncError) { + if (syncError instanceof SyncAccountMismatchError) { + await logout(); + setLoggedIn(false); + setUserProfile(null); + toast.error("계정을 바로 전환할 수 없습니다", { + description: syncError.message, + }); + return; + } + toast.success("로그인 성공!", { + description: + "동기화는 완료하지 못했지만 로컬 데이터는 그대로 유지됩니다.", + }); } } else { sendAuthLoginFail("google", "login_failed", result.error || "알 수 없는 오류"); @@ -360,50 +359,44 @@ const GoogleOAuthSection = () => { } }; - // Called after email verification is complete - const handleVerificationComplete = async () => { - // Re-login to get member token - setIsLoading(true); - try { - const result = await startGoogleLogin(); - - if (result.success && !result.response.requiresSignup) { - setIsGuest(false); - setUserProfile(result.response.profile); - - // Load verified email - const kuMail = await getStorage('kuMail'); - if (kuMail) { - setVerifiedEmail(kuMail); - } - - toast.success("회원가입 완료!", { - description: "이제 모든 기능을 사용할 수 있습니다.", - }); - } else { - // Still guest after re-login (edge case) - toast.error("인증에 문제가 발생했습니다. 다시 시도해주세요."); - } - } catch (error) { - errorLog("Re-login error:", error); - toast.error("재로그인에 실패했습니다."); - } finally { - setIsLoading(false); - } - }; - const handleLogout = async () => { sendAuthLogout("settings_dialog"); await logout(); setLoggedIn(false); - setIsGuest(false); setUserProfile(null); - setVerifiedEmail(null); toast.success("로그아웃 완료"); }; + const handleDeleteAccount = async () => { + if ( + !confirm( + "계정에 동기화된 템플릿과 공유 데이터를 모두 삭제할까요? 이 기기의 로컬 템플릿은 유지됩니다.", + ) + ) { + return; + } + setIsDeleting(true); + try { + await deleteCloudAccount(); + await logout(); + await clearAccountSyncState(); + setLoggedIn(false); + setUserProfile(null); + toast.success("계정 데이터 삭제 완료", { + description: "이 기기에 저장된 템플릿은 그대로 남아 있습니다.", + }); + } catch (error) { + toast.error("계정 데이터 삭제 실패", { + description: + error instanceof Error ? error.message : "잠시 후 다시 시도해주세요.", + }); + } finally { + setIsDeleting(false); + } + }; + // Get initials for avatar fallback const getInitials = (name: string): string => { if (!name) return "??"; @@ -419,13 +412,13 @@ const GoogleOAuthSection = () => { // Not logged in - show login button return (
-

Google / Konkuk 계정 연동

+

LinKU 계정 동기화

- 계정 연동을 하면 템플릿을 서버에 저장하고 여러 기기에서 동기화할 수 있습니다. + Google 로그인만으로 계정 기능을 바로 이용하고 템플릿을 여러 기기에서 동기화할 수 있습니다.

@@ -441,54 +434,10 @@ const GoogleOAuthSection = () => { ); } - // Guest user - show email verification prompt - if (isGuest) { - return ( - <> -
-

이메일 인증 필요

- -
-
- -

- 건국대학교 이메일 인증을 완료해야 템플릿 동기화 기능을 사용할 수 있습니다. -

-
- - - - -
-
- - - - ); - } - - // Logged in as member - show user profile + // A verified Google account can use sync without a separate school-email gate. return (
-

Google / Konkuk 계정 연동

+

LinKU 계정 동기화

@@ -504,7 +453,7 @@ const GoogleOAuthSection = () => { {userProfile?.name || "사용자"}

- {verifiedEmail || userProfile?.email || "인증된 사용자"} + {userProfile?.email || "Google 계정"}

@@ -517,6 +466,17 @@ const GoogleOAuthSection = () => {
+ +

+ R2에 동기화된 데이터와 로그인 세션만 삭제합니다. 이 기기의 로컬 템플릿은 유지됩니다. +

); diff --git a/src/pages/TemplateListPage.tsx b/src/pages/TemplateListPage.tsx index df37367..af41d91 100644 --- a/src/pages/TemplateListPage.tsx +++ b/src/pages/TemplateListPage.tsx @@ -1,6 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; -import { FileText, FileUp, LayoutTemplate, Plus, Sparkles } from 'lucide-react'; +import { + FileText, + FileUp, + LayoutTemplate, + Plus, + RefreshCw, + Sparkles, +} from 'lucide-react'; import { TemplateCard } from '@/components/Editor/TemplatePreview/TemplateCard'; import { Button } from '@/components/ui/button'; import { @@ -19,6 +26,9 @@ import { importSharedTemplate, loadTemplateFromLocalStorage, } from '@/utils/templateStorage'; +import { getTemplateSyncState } from '@/storage/linkuDb'; +import { createCloudShare, syncAccount } from '@/utils/accountSync'; +import { isLoggedIn } from '@/utils/oauth'; import { createTemplateShareUrl, downloadTemplatePayload, @@ -38,7 +48,10 @@ import { sendTemplateDelete, } from '@/utils/analytics'; -function toSummary(template: Template): TemplateSummary { +function toSummary( + template: Template, + syncStatus: TemplateSummary['syncStatus'] = 'local', +): TemplateSummary { return { templateId: template.templateId, name: template.name, @@ -47,7 +60,7 @@ function toSummary(template: Template): TemplateSummary { createdAt: template.createdAt, updatedAt: template.updatedAt, itemCount: template.items.length, - syncStatus: 'local', + syncStatus, items: template.items, }; } @@ -63,7 +76,9 @@ export const TemplateListPage = () => { const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState<'owned' | 'cloned'>('owned'); const [actionLoading, setActionLoading] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); const importInputRef = useRef(null); + const autoSyncAttempted = useRef(false); useEffect(() => { const applyBulletin = (bulletin: Parameters[0]) => { @@ -81,11 +96,16 @@ export const TemplateListPage = () => { const storedTemplates = await Promise.all( index.map((entry) => loadTemplateFromLocalStorage(entry.templateId)), ); - setTemplates( - storedTemplates - .filter((stored) => stored !== null) - .map((stored) => toSummary(stored.template)), + const available = storedTemplates.filter((stored) => stored !== null); + const summaries = await Promise.all( + available.map(async (stored) => + toSummary( + stored.template, + await getTemplateSyncState(stored.template.id), + ), + ), ); + setTemplates(summaries); } catch (error) { errorLog('Failed to load IndexedDB templates', error); setTemplates([]); @@ -103,6 +123,20 @@ export const TemplateListPage = () => { void loadTemplates(); }, [loadTemplates]); + useEffect(() => { + if (autoSyncAttempted.current) return; + autoSyncAttempted.current = true; + void isLoggedIn() + .then(async (loggedIn) => { + if (!loggedIn) return; + await syncAccount(); + await loadTemplates(); + }) + .catch((error: unknown) => { + errorLog('Background account sync deferred', error); + }); + }, [loadTemplates]); + useEffect(() => { const handleTemplatesChanged = () => void loadTemplates(); window.addEventListener('linku:templates-changed', handleTemplatesChanged); @@ -196,11 +230,32 @@ export const TemplateListPage = () => { description: '템플릿 데이터는 링크의 fragment에만 들어 있습니다.', }); } else { - downloadTemplatePayload(share.payload); - toast({ - title: '공유 파일 저장 완료', - description: '링크에 담기 큰 템플릿이라 파일로 저장했습니다.', - }); + if (await isLoggedIn()) { + let url: string; + try { + url = await createCloudShare(share.payload); + } catch (cloudError) { + errorLog('Cloud share unavailable; downloading file instead', cloudError); + downloadTemplatePayload(share.payload); + toast({ + title: '공유 파일 저장 완료', + description: + '계정 서버에 연결할 수 없어 큰 템플릿을 파일로 저장했습니다.', + }); + return; + } + await navigator.clipboard.writeText(url); + toast({ + title: '클라우드 공유 링크 복사 완료', + description: '큰 템플릿 공유는 30일 후 만료됩니다.', + }); + } else { + downloadTemplatePayload(share.payload); + toast({ + title: '공유 파일 저장 완료', + description: '로그인하지 않아 큰 템플릿을 파일로 저장했습니다.', + }); + } } } catch (error) { errorLog('Failed to share template', error); @@ -215,6 +270,37 @@ export const TemplateListPage = () => { } }; + const handleSync = async () => { + setIsSyncing(true); + try { + const result = await syncAccount(); + await loadTemplates(); + if (result.failed > 0) { + toast({ + title: '일부 동기화 실패', + description: `${result.failed}개 변경은 이 기기에 보존되어 있습니다. 다시 시도해주세요.`, + variant: 'destructive', + }); + } else { + toast({ + title: '동기화 완료', + description: `업로드 ${result.synced}개 · 내려받기 ${result.pulled}개`, + }); + } + } catch (error) { + toast({ + title: '동기화하지 못했습니다', + description: + error instanceof Error + ? error.message + : '로그인과 네트워크 상태를 확인해주세요.', + variant: 'destructive', + }); + } finally { + setIsSyncing(false); + } + }; + const handleImportFile = async (file: File | undefined) => { if (!file) return; try { @@ -305,6 +391,10 @@ export const TemplateListPage = () => {

+ diff --git a/src/storage/linkuDb.ts b/src/storage/linkuDb.ts index 0ec7833..2e84793 100644 --- a/src/storage/linkuDb.ts +++ b/src/storage/linkuDb.ts @@ -19,6 +19,28 @@ export interface StoredAsset { createdAt: number; } +export type SyncOperation = "put" | "delete"; + +export interface SyncOutboxEntry { + key: string; + resourceId: string; + operation: SyncOperation; + queuedAt: number; + attempts: number; +} + +export interface SyncMetadata { + key: string; + etag?: string; + lastSyncedAt?: number; + lastError?: string; +} + +interface StoredSetting { + key: string; + value: unknown; +} + interface LinkuDatabase extends DBSchema { templates: { key: number; @@ -38,25 +60,57 @@ interface LinkuDatabase extends DBSchema { key: string; value: { completedAt: number }; }; + settings: { + key: string; + value: StoredSetting; + }; + outbox: { + key: string; + value: SyncOutboxEntry; + indexes: { "by-queued-at": number }; + }; + syncMeta: { + key: string; + value: SyncMetadata; + }; } const DATABASE_NAME = "linku"; -const DATABASE_VERSION = 1; +const DATABASE_VERSION = 2; +const ACTIVE_ACCOUNT_KEY = "active-sync-account"; let databasePromise: Promise> | undefined; +export class SyncAccountMismatchError extends Error { + constructor() { + super( + "이 Chrome 프로필은 다른 Google 계정의 동기화 데이터와 연결되어 있습니다. 기존 계정으로 다시 로그인하거나 계정 데이터를 삭제한 뒤 전환해주세요.", + ); + this.name = "SyncAccountMismatchError"; + } +} + export function getLinkuDb(): Promise> { if (!databasePromise) { databasePromise = openDB(DATABASE_NAME, DATABASE_VERSION, { - upgrade(database) { - const templates = database.createObjectStore("templates"); - templates.createIndex("by-last-saved", "metadata.lastSaved"); - database.createObjectStore("drafts"); + upgrade(database, oldVersion) { + if (oldVersion < 1) { + const templates = database.createObjectStore("templates"); + templates.createIndex("by-last-saved", "metadata.lastSaved"); + database.createObjectStore("drafts"); + + const assets = database.createObjectStore("assets", { keyPath: "id" }); + assets.createIndex("by-numeric-id", "numericId", { unique: true }); - const assets = database.createObjectStore("assets", { keyPath: "id" }); - assets.createIndex("by-numeric-id", "numericId", { unique: true }); + database.createObjectStore("migrations"); + } - database.createObjectStore("migrations"); + if (oldVersion < 2) { + database.createObjectStore("settings", { keyPath: "key" }); + const outbox = database.createObjectStore("outbox", { keyPath: "key" }); + outbox.createIndex("by-queued-at", "queuedAt"); + database.createObjectStore("syncMeta", { keyPath: "key" }); + } }, blocked() { databasePromise = undefined; @@ -72,3 +126,111 @@ export function getLinkuDb(): Promise> { return databasePromise; } + +export async function listSyncOutbox(): Promise { + const database = await getLinkuDb(); + return database.getAllFromIndex("outbox", "by-queued-at"); +} + +export async function removeSyncOutboxEntry(key: string): Promise { + const database = await getLinkuDb(); + await database.delete("outbox", key); +} + +export async function markSyncAttempt( + outboxKey: string, + metadataKey: string, + message: string, +): Promise { + const database = await getLinkuDb(); + const transaction = database.transaction(["outbox", "syncMeta"], "readwrite"); + const outbox = transaction.objectStore("outbox"); + const current = await outbox.get(outboxKey); + if (current) { + await outbox.put({ ...current, attempts: current.attempts + 1 }); + } + const metadata = await transaction.objectStore("syncMeta").get(metadataKey); + await transaction.objectStore("syncMeta").put({ + ...metadata, + key: metadataKey, + lastError: message, + }); + await transaction.done; +} + +export async function getSyncMetadata( + key: string, +): Promise { + const database = await getLinkuDb(); + return database.get("syncMeta", key); +} + +export async function setSyncMetadata(metadata: SyncMetadata): Promise { + const database = await getLinkuDb(); + await database.put("syncMeta", metadata); +} + +export async function activateSyncAccount(accountId: string): Promise { + const database = await getLinkuDb(); + const current = await database.get("settings", ACTIVE_ACCOUNT_KEY); + if (current?.value === accountId) return false; + if (typeof current?.value === "string") { + throw new SyncAccountMismatchError(); + } + + const transaction = database.transaction( + ["templates", "settings", "outbox"], + "readwrite", + ); + const templates = await transaction.objectStore("templates").getAll(); + await transaction.objectStore("outbox").clear(); + const queuedAt = Date.now(); + for (const stored of templates) { + const resourceId = stored.template.id; + await transaction.objectStore("outbox").put({ + key: `template:${resourceId}`, + resourceId, + operation: "put", + queuedAt, + attempts: 0, + }); + } + await transaction.objectStore("settings").put({ + key: ACTIVE_ACCOUNT_KEY, + value: accountId, + }); + await transaction.done; + return true; +} + +export async function clearAccountSyncState(): Promise { + const database = await getLinkuDb(); + const transaction = database.transaction( + ["settings", "outbox", "syncMeta"], + "readwrite", + ); + await Promise.all([ + transaction.objectStore("settings").delete(ACTIVE_ACCOUNT_KEY), + transaction.objectStore("outbox").clear(), + transaction.objectStore("syncMeta").clear(), + ]); + await transaction.done; +} + +export async function getTemplateSyncState( + resourceId: string, +): Promise<"local" | "pending" | "synced" | "error"> { + const database = await getLinkuDb(); + const outboxKey = `template:${resourceId}`; + const account = await database.get("settings", ACTIVE_ACCOUNT_KEY); + const accountId = typeof account?.value === "string" ? account.value : null; + if (!accountId) return "local"; + const metadataKey = `${accountId}:template:${resourceId}`; + const [pending, metadata] = await Promise.all([ + database.get("outbox", outboxKey), + database.get("syncMeta", metadataKey), + ]); + if (pending && metadata?.lastError) return "error"; + if (pending) return "pending"; + return metadata?.etag ? "synced" : "local"; +} diff --git a/src/types/api.ts b/src/types/api.ts index 9a0569d..962297d 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -176,7 +176,7 @@ export interface Template extends BaseEntity { height: number; // Template height in rows (e.g., 6 for 6-row grid) cloned: boolean; items: TemplateItem[]; - syncStatus?: 'local' | 'synced'; // Local-only or synced with server + syncStatus?: 'local' | 'pending' | 'synced' | 'error'; } /** @@ -217,7 +217,7 @@ export interface TemplateSummary { createdAt: string; updatedAt: string; itemCount?: number; - syncStatus?: 'local' | 'synced'; // Local-only or synced with server + syncStatus?: 'local' | 'pending' | 'synced' | 'error'; previewUrl?: string; items?: TemplateItem[]; // For preview rendering } @@ -315,70 +315,6 @@ export interface LikeTemplateResponse { likeCount: number; } -// ============================================================================ -// Auth -// ============================================================================ - -/** - * Google OAuth authorization request - */ -export interface GoogleOAuthRequest { - authorizationCode: string; - redirectUri: string; -} - -/** - * Google OAuth authorization response - */ -export interface GoogleOAuthResponse { - guestToken: string; - requiresSignup: boolean; - expiresAt: string; - profile: { - email: string; - name: string; - picture: string; - }; -} - -/** - * Send verification code request - * POST /auth/send-code - */ -export interface SendCodeRequest { - kuMail: string; // 건국대 이메일 (@konkuk.ac.kr) -} - -/** - * Send verification code response - * 성공 시 result: null - */ -export type SendCodeResponse = null; - -/** - * Verify code request - * POST /auth/verify-code - */ -export interface VerifyCodeRequest { - kuMail: string; // 건국대 이메일 (@konkuk.ac.kr) - authCode: string; // 6자리 인증 코드 -} - -/** - * Verify code response - * 성공 시 result: null - */ -export type VerifyCodeResponse = null; - -/** - * Auth error codes - */ -export const AUTH_ERROR_CODES = { - INVALID_INPUT: 1005, // 입력 값이 유효하지 않습니다 - DUPLICATE_EMAIL: 5014, // 이미 존재하는 건국대학교 이메일입니다 - INVALID_CODE: 5015, // 인증 코드가 올바르지 않습니다 -} as const; - // ============================================================================ // Alerts // ============================================================================ diff --git a/src/types/serverless.ts b/src/types/serverless.ts new file mode 100644 index 0000000..94026b3 --- /dev/null +++ b/src/types/serverless.ts @@ -0,0 +1,69 @@ +import type { PortableTemplateItem, TemplateSharePayloadV1 } from "@/types/templateShare"; + +export type ApiErrorCode = + | "UNAUTHENTICATED" + | "FORBIDDEN" + | "CONFLICT" + | "INVALID_REQUEST" + | "PAYLOAD_TOO_LARGE" + | "SHARE_NOT_FOUND" + | "RATE_LIMITED" + | "UPSTREAM_UNAVAILABLE"; + +export type ApiResult = + | { ok: true; data: T } + | { + ok: false; + error: { + code: ApiErrorCode; + message: string; + retryable: boolean; + requestId: string; + }; + }; + +export interface AuthProfile { + name: string; + email: string; + picture: string; +} + +export interface AuthSessionResponse { + accessToken: string; + refreshToken: string; + expiresAt: string; + accountId: string; + profile: AuthProfile; +} + +export interface SyncedTemplateDocumentV1 { + version: 1; + id: string; + name: string; + height: number; + cloned: boolean; + items: PortableTemplateItem[]; + stagingItems: PortableTemplateItem[]; + createdAt: string; + updatedAt: string; +} + +export interface SyncIndexEntry { + id: string; + etag: string; + size: number; + uploadedAt: string; + deleted?: boolean; +} + +export interface SyncIndexResponse { + templates: SyncIndexEntry[]; +} + +export interface CloudShareRecord { + version: 1; + createdAt: string; + expiresAt: string; + ownerId: string; + payload: TemplateSharePayloadV1; +} diff --git a/src/utils/accountSync.ts b/src/utils/accountSync.ts new file mode 100644 index 0000000..e802df3 --- /dev/null +++ b/src/utils/accountSync.ts @@ -0,0 +1,464 @@ +import { + activateSyncAccount, + getSyncMetadata, + listSyncOutbox, + markSyncAttempt, + removeSyncOutboxEntry, + setSyncMetadata, +} from "@/storage/linkuDb"; +import type { + ApiResult, + SyncIndexResponse, + SyncedTemplateDocumentV1, +} from "@/types/serverless"; +import type { TemplateSharePayloadV1 } from "@/types/templateShare"; +import { + findTemplateBySyncId, + importSharedTemplate, + removeLocalTemplateWithoutSync, + saveRemoteTemplate, +} from "@/utils/templateStorage"; +import { + portableItemsToTemplateItems, + templateItemsToPortable, +} from "@/utils/templateShare"; +import { validateTemplateSharePayload } from "@/utils/templateShareCodec"; +import { + ACCOUNT_ID_KEY, +} from "@/utils/authStorage"; +import { + ACCOUNT_API_BASE_URL, + getValidAccessToken, +} from "@/background/handlers/oauth"; + +interface SyncPassResult { + synced: number; + failed: number; +} + +interface PullPassResult { + pulled: number; + failed: number; +} + +type PutTemplateResult = "synced" | "conflict"; + +interface RemoteTemplate { + document: SyncedTemplateDocumentV1; + etag: string; +} + +function isSyncedTemplateDocument( + value: unknown, + expectedId: string, +): value is SyncedTemplateDocumentV1 { + if (!value || typeof value !== "object") return false; + const document = value as Record; + if ( + document.version !== 1 || + document.id !== expectedId || + typeof document.name !== "string" || + typeof document.height !== "number" || + typeof document.cloned !== "boolean" || + !Array.isArray(document.items) || + !Array.isArray(document.stagingItems) || + typeof document.createdAt !== "string" || + !Number.isFinite(Date.parse(document.createdAt)) || + typeof document.updatedAt !== "string" || + !Number.isFinite(Date.parse(document.updatedAt)) + ) { + return false; + } + + try { + validateTemplateSharePayload({ + version: 1, + template: { + name: document.name, + height: document.height, + items: document.items, + }, + }); + validateTemplateSharePayload({ + version: 1, + template: { + name: document.name, + height: document.height, + items: document.stagingItems, + }, + }); + return true; + } catch { + return false; + } +} + +function isSyncIndexResponse(value: unknown): value is SyncIndexResponse { + if (!value || typeof value !== "object") return false; + const templates = (value as Record).templates; + return ( + Array.isArray(templates) && + templates.every((entry) => { + if (!entry || typeof entry !== "object") return false; + const item = entry as Record; + return ( + typeof item.id === "string" && + /^[A-Za-z0-9:_-]{1,128}$/u.test(item.id) && + typeof item.etag === "string" && + typeof item.size === "number" && + item.size >= 0 && + typeof item.uploadedAt === "string" && + Number.isFinite(Date.parse(item.uploadedAt)) && + (item.deleted === undefined || typeof item.deleted === "boolean") + ); + }) + ); +} + +function metadataKey(accountId: string, resourceId: string): string { + return `${accountId}:template:${resourceId}`; +} + +async function accountId(): Promise { + const stored = await chrome.storage.local.get(ACCOUNT_ID_KEY); + const value = stored[ACCOUNT_ID_KEY]; + if (typeof value !== "string") { + throw new Error("계정 동기화를 사용하려면 로그인해주세요."); + } + return value; +} + +async function authenticatedFetch( + path: string, + init: RequestInit = {}, +): Promise { + const token = await getValidAccessToken(); + if (!token) throw new Error("계정 동기화를 사용하려면 로그인해주세요."); + const headers = new Headers(init.headers); + headers.set("authorization", `Bearer ${token}`); + return fetch(`${ACCOUNT_API_BASE_URL}${path}`, { ...init, headers }); +} + +async function readApiError(response: Response): Promise { + try { + const result = (await response.json()) as ApiResult; + return result.ok ? response.statusText : result.error.message; + } catch { + return response.statusText || "계정 서버 요청에 실패했습니다."; + } +} + +function toDocument( + stored: NonNullable>>, +): SyncedTemplateDocumentV1 { + return { + version: 1, + id: stored.template.id, + name: stored.template.name, + height: stored.template.height, + cloned: stored.template.cloned, + items: templateItemsToPortable(stored.template.items), + stagingItems: templateItemsToPortable(stored.stagingItems), + createdAt: stored.template.createdAt, + updatedAt: stored.template.updatedAt, + }; +} + +async function readRemoteTemplate( + resourceId: string, +): Promise { + const response = await authenticatedFetch( + `/sync/v1/templates/${encodeURIComponent(resourceId)}`, + ); + if (response.status === 404) return null; + if (!response.ok) throw new Error(await readApiError(response)); + const document: unknown = await response.json(); + if (!isSyncedTemplateDocument(document, resourceId)) { + throw new Error("계정 서버의 템플릿 데이터가 올바르지 않습니다."); + } + const etag = response.headers.get("etag")?.replace(/"/gu, ""); + if (!etag) throw new Error("계정 서버의 변경 식별자가 없습니다."); + return { document, etag }; +} + +async function putTemplate( + currentAccountId: string, + resourceId: string, +): Promise { + const stored = await findTemplateBySyncId(resourceId); + const outboxKey = `template:${resourceId}`; + if (!stored) { + await removeSyncOutboxEntry(outboxKey); + return "synced"; + } + + const syncKey = metadataKey(currentAccountId, resourceId); + const metadata = await getSyncMetadata(syncKey); + const response = await authenticatedFetch( + `/sync/v1/templates/${encodeURIComponent(resourceId)}`, + { + method: "PUT", + headers: { + "content-type": "application/json", + ...(metadata?.etag + ? { "if-match": `"${metadata.etag}"` } + : { "if-none-match": "*" }), + }, + body: JSON.stringify(toDocument(stored)), + }, + ); + if (response.status === 412) { + await preserveConflict(currentAccountId, resourceId, stored.template.templateId); + return "conflict"; + } + if (!response.ok) throw new Error(await readApiError(response)); + const etag = response.headers.get("etag")?.replace(/"/gu, ""); + await Promise.all([ + setSyncMetadata({ + key: syncKey, + etag: etag || undefined, + lastSyncedAt: Date.now(), + }), + removeSyncOutboxEntry(outboxKey), + ]); + return "synced"; +} + +async function preserveConflict( + currentAccountId: string, + resourceId: string, + localTemplateId: number, +): Promise { + const local = await findTemplateBySyncId(resourceId); + if (!local) return; + const remote = await readRemoteTemplate(resourceId); + if (!remote) return; + await importSharedTemplate( + { ...local.template, cloned: true }, + local.stagingItems, + "(충돌 복사본)", + ); + await saveRemoteTemplate( + { + ...local.template, + id: remote.document.id, + name: remote.document.name, + height: remote.document.height, + cloned: remote.document.cloned, + items: portableItemsToTemplateItems(remote.document.items), + createdAt: remote.document.createdAt, + updatedAt: remote.document.updatedAt, + syncStatus: "synced", + }, + portableItemsToTemplateItems(remote.document.stagingItems), + localTemplateId, + ); + await Promise.all([ + setSyncMetadata({ + key: metadataKey(currentAccountId, resourceId), + etag: remote.etag, + lastSyncedAt: Date.now(), + }), + removeSyncOutboxEntry(`template:${resourceId}`), + ]); +} + +async function deleteTemplate( + currentAccountId: string, + resourceId: string, +): Promise { + const outboxKey = `template:${resourceId}`; + const metadata = await getSyncMetadata( + metadataKey(currentAccountId, resourceId), + ); + if (!metadata?.etag) { + await removeSyncOutboxEntry(outboxKey); + return; + } + const response = await authenticatedFetch( + `/sync/v1/templates/${encodeURIComponent(resourceId)}`, + { + method: "DELETE", + headers: { "if-match": `"${metadata.etag}"` }, + }, + ); + if (response.status === 412) { + const remote = await readRemoteTemplate(resourceId); + if (!remote) { + await removeSyncOutboxEntry(outboxKey); + return; + } + await importSharedTemplate( + { + id: remote.document.id, + templateId: Date.now(), + name: remote.document.name, + height: remote.document.height, + cloned: true, + items: portableItemsToTemplateItems(remote.document.items), + syncStatus: "local", + createdAt: remote.document.createdAt, + updatedAt: remote.document.updatedAt, + }, + portableItemsToTemplateItems(remote.document.stagingItems), + "(삭제 충돌 복사본)", + ); + const retry = await authenticatedFetch( + `/sync/v1/templates/${encodeURIComponent(resourceId)}`, + { + method: "DELETE", + headers: { "if-match": `"${remote.etag}"` }, + }, + ); + if (!retry.ok && retry.status !== 404) { + throw new Error(await readApiError(retry)); + } + await removeSyncOutboxEntry(outboxKey); + return; + } + if (!response.ok && response.status !== 404) { + throw new Error(await readApiError(response)); + } + await removeSyncOutboxEntry(outboxKey); +} + +async function pushLocalChanges( + currentAccountId: string, +): Promise { + const entries = await listSyncOutbox(); + let synced = 0; + let failed = 0; + for (const entry of entries) { + try { + if (entry.operation === "delete") { + await deleteTemplate(currentAccountId, entry.resourceId); + synced += 1; + } else { + const result = await putTemplate(currentAccountId, entry.resourceId); + if (result === "conflict") { + failed += 1; + } else { + synced += 1; + } + } + } catch (error) { + failed += 1; + await markSyncAttempt( + entry.key, + metadataKey(currentAccountId, entry.resourceId), + error instanceof Error ? error.message : "동기화 실패", + ); + } + } + return { synced, failed }; +} + +async function pullRemoteTemplates( + currentAccountId: string, +): Promise { + const response = await authenticatedFetch("/sync/v1/index"); + if (!response.ok) throw new Error(await readApiError(response)); + const result = (await response.json()) as ApiResult; + if (!result.ok) throw new Error(result.error.message); + if (!isSyncIndexResponse(result.data)) { + throw new Error("계정 서버의 동기화 목록이 올바르지 않습니다."); + } + const outbox = await listSyncOutbox(); + let pulled = 0; + let failed = 0; + + for (const remote of result.data.templates) { + try { + const syncKey = metadataKey(currentAccountId, remote.id); + const local = await findTemplateBySyncId(remote.id); + const pending = outbox.find( + (entry) => entry.key === `template:${remote.id}`, + ); + if (remote.deleted) { + if (!local) continue; + if (pending?.operation === "put") { + await importSharedTemplate( + { ...local.template, cloned: true }, + local.stagingItems, + "(복구본)", + ); + } + await removeLocalTemplateWithoutSync(local.template.templateId); + await removeSyncOutboxEntry(`template:${remote.id}`); + await setSyncMetadata({ + key: syncKey, + etag: remote.etag, + lastSyncedAt: Date.now(), + }); + pulled += 1; + continue; + } + + const metadata = await getSyncMetadata(syncKey); + if (metadata?.etag === remote.etag || pending) continue; + const remoteTemplate = await readRemoteTemplate(remote.id); + if (!remoteTemplate) continue; + const document = remoteTemplate.document; + await saveRemoteTemplate( + { + id: document.id, + templateId: local?.template.templateId ?? Date.now(), + name: document.name, + height: document.height, + cloned: document.cloned, + items: portableItemsToTemplateItems(document.items), + syncStatus: "synced", + createdAt: document.createdAt, + updatedAt: document.updatedAt, + }, + portableItemsToTemplateItems(document.stagingItems), + local?.template.templateId, + ); + await setSyncMetadata({ + key: syncKey, + etag: remote.etag, + lastSyncedAt: Date.now(), + }); + pulled += 1; + } catch { + failed += 1; + } + } + return { pulled, failed }; +} + +export async function syncAccount(): Promise<{ + synced: number; + failed: number; + pulled: number; +}> { + const currentAccountId = await accountId(); + await activateSyncAccount(currentAccountId); + const pushed = await pushLocalChanges(currentAccountId); + const pulled = await pullRemoteTemplates(currentAccountId); + window.dispatchEvent(new CustomEvent("linku:templates-changed")); + return { + synced: pushed.synced, + pulled: pulled.pulled, + failed: pushed.failed + pulled.failed, + }; +} + +export async function createCloudShare( + payload: TemplateSharePayloadV1, +): Promise { + const response = await authenticatedFetch("/shares/v1", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(await readApiError(response)); + const result = (await response.json()) as ApiResult<{ id: string; url: string }>; + if (!result.ok) throw new Error(result.error.message); + return result.data.url; +} + +export async function deleteCloudAccount(): Promise { + const response = await authenticatedFetch("/account", { method: "DELETE" }); + if (!response.ok) throw new Error(await readApiError(response)); +} diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index bfece33..209a32a 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -16,7 +16,7 @@ * - Lifecycle : sendExtensionOpen * - Search : sendSearchSubmit * - Auth : sendAuthLoginStart, sendAuthLoginSuccess, sendAuthLoginFail, - * sendAuthLogout, sendAuthEmailVerificationStart/Success + * sendAuthLogout * - Settings : sendSettingsOpen, sendSettingsCredentialsSaved, sendSettingsCredentialsDeleted * - System : sendError * - Template : sendTemplateEditorView, sendTemplateCreateStart, @@ -435,15 +435,12 @@ export async function sendAuthLoginStart( /** * 로그인 성공 이벤트 전송 * @param provider 인증 제공자 (예: "google") - * @param isGuest 게스트 계정 여부 */ export async function sendAuthLoginSuccess( provider: string, - isGuest: boolean ): Promise { await sendGAEvent("MP_authLogin_success", { provider, - is_guest: isGuest, }); } @@ -473,28 +470,6 @@ export async function sendAuthLogout(uiLocation: string): Promise { await sendGAEvent("MP_auth_logout", { ui_location: uiLocation }); } -/** - * 이메일 인증 시작 이벤트 전송 (게스트 → 정회원 전환 시작) - * @param uiLocation 버튼이 위치한 UI (예: "settings_dialog") - */ -export async function sendAuthEmailVerificationStart( - uiLocation: string -): Promise { - await sendGAEvent("MP_authEmailVerification_start", { - ui_location: uiLocation, - }); -} - -/** - * 이메일 인증 완료 이벤트 전송 (게스트 → 정회원 전환 완료) - * @param domainType 인증된 이메일 도메인 유형 (예: "konkuk.ac.kr") - */ -export async function sendAuthEmailVerificationSuccess( - domainType: string -): Promise { - await sendGAEvent("MP_authEmailVerification_success", { domain_type: domainType }); -} - // ─── Settings ───────────────────────────────────────────────────────────── /** diff --git a/src/utils/authStorage.ts b/src/utils/authStorage.ts new file mode 100644 index 0000000..9b56090 --- /dev/null +++ b/src/utils/authStorage.ts @@ -0,0 +1,59 @@ +import type { AuthSessionResponse } from "@/types/serverless"; + +export const ACCOUNT_ACCESS_TOKEN_KEY = "linkuAccountAccessToken"; +export const ACCOUNT_ACCESS_TOKEN_EXPIRES_AT_KEY = + "linkuAccountAccessTokenExpiresAt"; +export const ACCOUNT_REFRESH_TOKEN_KEY = "linkuAccountRefreshToken"; +export const ACCOUNT_PROFILE_KEY = "linkuAccountProfile"; +export const ACCOUNT_ID_KEY = "linkuAccountId"; + +const SESSION_AUTH_KEYS = [ + ACCOUNT_ACCESS_TOKEN_KEY, + ACCOUNT_ACCESS_TOKEN_EXPIRES_AT_KEY, +] as const; +const LOCAL_AUTH_KEYS = [ + ACCOUNT_REFRESH_TOKEN_KEY, + ACCOUNT_PROFILE_KEY, + ACCOUNT_ID_KEY, +] as const; +const LEGACY_SESSION_AUTH_KEYS = ["accessToken", "accessTokenExpiresAt"] as const; +const LEGACY_LOCAL_AUTH_KEYS = [ + "accessToken", + "refreshToken", + "userProfile", + "syncAccountId", + "guestToken", + "isGuest", + "kuMail", +] as const; + +export async function saveAuthSession( + session: AuthSessionResponse, +): Promise { + await Promise.all([ + chrome.storage.session.set({ + [ACCOUNT_ACCESS_TOKEN_KEY]: session.accessToken, + [ACCOUNT_ACCESS_TOKEN_EXPIRES_AT_KEY]: session.expiresAt, + }), + chrome.storage.local.set({ + [ACCOUNT_REFRESH_TOKEN_KEY]: session.refreshToken, + [ACCOUNT_PROFILE_KEY]: session.profile, + [ACCOUNT_ID_KEY]: session.accountId, + }), + chrome.storage.session.remove([...LEGACY_SESSION_AUTH_KEYS]), + chrome.storage.local.remove([...LEGACY_LOCAL_AUTH_KEYS]), + ]); +} + +export async function clearStoredAuth(): Promise { + await Promise.all([ + chrome.storage.session.remove([ + ...SESSION_AUTH_KEYS, + ...LEGACY_SESSION_AUTH_KEYS, + ]), + chrome.storage.local.remove([ + ...LOCAL_AUTH_KEYS, + ...LEGACY_LOCAL_AUTH_KEYS, + ]), + ]); +} diff --git a/src/utils/formValidation.ts b/src/utils/formValidation.ts index 4d941ab..d7a1428 100644 --- a/src/utils/formValidation.ts +++ b/src/utils/formValidation.ts @@ -33,26 +33,6 @@ export const linkFormSchema = z.object({ iconId: iconIdSchema, }); -const emailSchema = trimmedString().check( - z.minLength(1, "이메일을 입력해주세요."), - z.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, "올바른 이메일 형식을 입력해주세요."), -); - -export const konkukEmailSchema = z.pipe( - emailSchema, - trimmedString().check( - z.refine( - (email) => email.toLowerCase().endsWith("@konkuk.ac.kr"), - "건국대학교 이메일(@konkuk.ac.kr)만 사용 가능합니다.", - ), - ), -); - -export const authCodeSchema = trimmedString().check( - z.minLength(1, "인증 코드를 입력해주세요."), - z.regex(/^\d{6}$/, "6자리 숫자를 입력해주세요."), -); - export const todoInputSchema = z.object({ title: trimmedString().check(z.minLength(1, "할 일 제목을 입력해주세요.")), subject: trimmedString(), diff --git a/src/utils/oauth.ts b/src/utils/oauth.ts index 24bd062..c511858 100644 --- a/src/utils/oauth.ts +++ b/src/utils/oauth.ts @@ -1,30 +1,18 @@ -/** - * OAuth utilities for Chrome Extension - Popup Context - * Handles Google OAuth flow by communicating with background service worker - * - * Note: chrome.identity API is NOT available in popup context. - * All OAuth flows are handled by the background service worker. - */ - -import { BackgroundMessageType } from "../background/types"; -import type { GoogleLoginResponse } from "../background/types"; -import { getChromeApi, getStorage, removeStorage } from "./chrome"; +import { BackgroundMessageType } from "@/background/types"; +import type { GoogleLoginResponse } from "@/background/types"; +import type { AuthProfile } from "@/types/serverless"; +import { + ACCOUNT_ACCESS_TOKEN_KEY, + ACCOUNT_PROFILE_KEY, + ACCOUNT_REFRESH_TOKEN_KEY, + clearStoredAuth, +} from "@/utils/authStorage"; import { debugLog, errorLog, getErrorLogDetails } from "@/utils/logger"; -/** - * User profile stored in chrome.storage.local - */ -export interface UserProfile { - email: string; - name: string; - picture: string; -} +export type UserProfile = AuthProfile; function isUserProfile(value: unknown): value is UserProfile { - if (!value || typeof value !== "object") { - return false; - } - + if (!value || typeof value !== "object") return false; const profile = value as Record; return ( typeof profile.email === "string" && @@ -33,84 +21,43 @@ function isUserProfile(value: unknown): value is UserProfile { ); } -/** - * Get access token from chrome.storage.local - */ export async function getAccessToken(): Promise { - const token = await getStorage("accessToken"); + const stored = await chrome.storage.session.get(ACCOUNT_ACCESS_TOKEN_KEY); + const token = stored[ACCOUNT_ACCESS_TOKEN_KEY]; return typeof token === "string" ? token : null; } -/** - * Get user profile from chrome.storage.local - */ export async function getUserProfile(): Promise { - const profile = await getStorage("userProfile"); + const stored = await chrome.storage.local.get(ACCOUNT_PROFILE_KEY); + const profile = stored[ACCOUNT_PROFILE_KEY]; return isUserProfile(profile) ? profile : null; } -/** - * Clear all tokens and user profile from chrome.storage.local - */ export async function clearTokens(): Promise { - await removeStorage([ - "accessToken", - "refreshToken", - "guestToken", - "userProfile", - "isGuest", - "kuMail", - ]); + await clearStoredAuth(); } -/** - * Check if user is logged in - */ export async function isLoggedIn(): Promise { - const token = await getAccessToken(); - return !!token; -} - -/** - * Check if current user is a guest (needs email verification) - */ -export async function isGuestUser(): Promise { - const isGuest = await getStorage("isGuest"); - const refreshToken = await getStorage("refreshToken"); - // Guest if isGuest flag is true OR no refreshToken - return isGuest === true || !refreshToken; + const [token, local] = await Promise.all([ + getAccessToken(), + chrome.storage.local.get(ACCOUNT_REFRESH_TOKEN_KEY), + ]); + return Boolean( + token || typeof local[ACCOUNT_REFRESH_TOKEN_KEY] === "string", + ); } -/** - * Google OAuth login flow - * Sends message to background service worker to handle OAuth - * - * Background service worker has access to chrome.identity API - */ export async function startGoogleLogin(): Promise { - const chromeApi = getChromeApi(); - if (!chromeApi?.runtime?.sendMessage) { - return { - success: false, - error: "Chrome extension environment is unavailable.", - }; - } - try { debugLog("[Popup] Sending Google login request to background"); - - // Send message to background service worker - const response = await chromeApi.runtime.sendMessage({ + return (await chrome.runtime.sendMessage({ type: BackgroundMessageType.GOOGLE_LOGIN, - }); - - return response as GoogleLoginResponse; + })) as GoogleLoginResponse; } catch (error) { errorLog( "[Popup] Failed to communicate with background", getErrorLogDetails(error), ); - return { success: false, error: @@ -121,11 +68,11 @@ export async function startGoogleLogin(): Promise { } } -/** - * Logout - clear all tokens - */ export async function logout(): Promise { - await clearTokens(); - // Dispatch custom event for UI updates + try { + await chrome.runtime.sendMessage({ type: BackgroundMessageType.LOGOUT }); + } finally { + await clearTokens(); + } window.dispatchEvent(new CustomEvent("auth:logout")); } diff --git a/src/utils/templateShare.ts b/src/utils/templateShare.ts index f2891f1..43996ea 100644 --- a/src/utils/templateShare.ts +++ b/src/utils/templateShare.ts @@ -2,6 +2,7 @@ import { getBundledTemplateIcons } from "@/constants/templateIcons"; import type { Template, TemplateIcon, TemplateItem } from "@/types/api"; import type { PortableIcon, + PortableTemplateItem, TemplateSharePayloadV1, } from "@/types/templateShare"; import { @@ -68,6 +69,31 @@ function fromPortableIcon(icon: PortableIcon): TemplateIcon { }; } +export function templateItemsToPortable( + items: TemplateItem[], +): PortableTemplateItem[] { + return items.map((item) => ({ + name: item.name, + siteUrl: item.siteUrl, + position: item.position, + size: item.size, + icon: toPortableIcon(item.icon), + })); +} + +export function portableItemsToTemplateItems( + items: PortableTemplateItem[], +): TemplateItem[] { + return items.map((item, index) => ({ + templateItemId: -(index + 1), + name: item.name, + siteUrl: item.siteUrl, + position: item.position, + size: item.size, + icon: fromPortableIcon(item.icon), + })); +} + export function createTemplateSharePayload( template: Template, ): TemplateSharePayloadV1 { @@ -76,13 +102,7 @@ export function createTemplateSharePayload( template: { name: template.name.slice(0, 80), height: template.height, - items: template.items.map((item) => ({ - name: item.name, - siteUrl: item.siteUrl, - position: item.position, - size: item.size, - icon: toPortableIcon(item.icon), - })), + items: templateItemsToPortable(template.items), }, }; } @@ -92,14 +112,7 @@ export function portablePayloadToTemplate( ): Template { validateTemplateSharePayload(payload); const now = new Date().toISOString(); - const items: TemplateItem[] = payload.template.items.map((item, index) => ({ - templateItemId: -(index + 1), - name: item.name, - siteUrl: item.siteUrl, - position: item.position, - size: item.size, - icon: fromPortableIcon(item.icon), - })); + const items = portableItemsToTemplateItems(payload.template.items); return { id: crypto.randomUUID(), templateId: Date.now(), diff --git a/src/utils/templateStorage.ts b/src/utils/templateStorage.ts index 1335525..d59cc86 100644 --- a/src/utils/templateStorage.ts +++ b/src/utils/templateStorage.ts @@ -187,7 +187,21 @@ export async function saveTemplateToLocalStorage( if (template.templateId === 0) { await database.put("drafts", stored, "current"); } else { - await database.put("templates", stored, template.templateId); + const transaction = database.transaction( + ["templates", "outbox"], + "readwrite", + ); + await transaction + .objectStore("templates") + .put(stored, template.templateId); + await transaction.objectStore("outbox").put({ + key: `template:${stored.template.id}`, + resourceId: stored.template.id, + operation: "put", + queuedAt: now, + attempts: 0, + }); + await transaction.done; } } catch (error) { errorLog("Failed to save template to IndexedDB", error); @@ -227,7 +241,22 @@ export async function deleteTemplateFromLocalStorage( ): Promise { await ensureMigration(); const database = await getLinkuDb(); - await database.delete("templates", templateId); + const transaction = database.transaction( + ["templates", "outbox"], + "readwrite", + ); + const stored = await transaction.objectStore("templates").get(templateId); + await transaction.objectStore("templates").delete(templateId); + if (stored) { + await transaction.objectStore("outbox").put({ + key: `template:${stored.template.id}`, + resourceId: stored.template.id, + operation: "delete", + queuedAt: Date.now(), + attempts: 0, + }); + } + await transaction.done; if (typeof localStorage !== "undefined") { try { @@ -260,10 +289,14 @@ export function checkTemplateStorageAvailability(): { export async function importSharedTemplate( template: Template, stagingItems: TemplateItem[] = [], + nameSuffix = "(가져옴)", ): Promise { await ensureMigration(); const database = await getLinkuDb(); - const transaction = database.transaction("templates", "readwrite"); + const transaction = database.transaction( + ["templates", "outbox"], + "readwrite", + ); const store = transaction.objectStore("templates"); let templateId = Date.now(); while (await store.get(templateId)) templateId += 1; @@ -273,9 +306,9 @@ export async function importSharedTemplate( ...template, id: crypto.randomUUID(), templateId, - name: template.name.endsWith("(가져옴)") + name: template.name.endsWith(nameSuffix) ? template.name - : `${template.name} (가져옴)`, + : `${template.name} ${nameSuffix}`, cloned: true, syncStatus: "local", createdAt: now, @@ -290,6 +323,60 @@ export async function importSharedTemplate( }, }; await store.put(stored, templateId); + await transaction.objectStore("outbox").put({ + key: `template:${imported.id}`, + resourceId: imported.id, + operation: "put", + queuedAt: Date.now(), + attempts: 0, + }); + await transaction.done; + return stored; +} + +export async function findTemplateBySyncId( + resourceId: string, +): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + const templates = await database.getAll("templates"); + return templates.find((stored) => stored.template.id === resourceId) ?? null; +} + +export async function saveRemoteTemplate( + template: Template, + stagingItems: TemplateItem[] = [], + existingTemplateId?: number, +): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + const transaction = database.transaction("templates", "readwrite"); + const store = transaction.objectStore("templates"); + let templateId = existingTemplateId ?? Date.now(); + if (existingTemplateId === undefined) { + while (await store.get(templateId)) templateId += 1; + } + const stored: StoredTemplate = { + template: { + ...template, + templateId, + syncStatus: "synced", + }, + stagingItems, + metadata: { + lastSaved: Date.now(), + savedLocally: true, + }, + }; + await store.put(stored, templateId); await transaction.done; return stored; } + +export async function removeLocalTemplateWithoutSync( + templateId: number, +): Promise { + await ensureMigration(); + const database = await getLinkuDb(); + await database.delete("templates", templateId); +} diff --git a/src/web/share.tsx b/src/web/share.tsx index d74d917..69dbeb5 100644 --- a/src/web/share.tsx +++ b/src/web/share.tsx @@ -5,15 +5,18 @@ import { Button } from '@/components/ui/button'; import { TemplatePreviewCanvas } from '@/components/Editor/TemplatePreview/TemplatePreviewCanvas'; import type { Template } from '@/types/api'; import type { TemplateSharePayloadV1 } from '@/types/templateShare'; +import type { ApiResult } from '@/types/serverless'; import { decodeTemplateSharePayload, downloadTemplatePayload, portablePayloadToTemplate, + validateTemplateSharePayload, } from '@/utils/templateShare'; import '@/App.css'; const EXTENSION_ID = 'fmfbhmifnohhfiblebbdjlioppfppbgh'; const EXTENSION_URL = `https://chromewebstore.google.com/detail/linku/${EXTENSION_ID}`; +const CLOUD_SHARE_API = 'https://linku.turtlehwan.dev/api/shares/v1'; interface ImportResponse { success?: boolean; @@ -36,6 +39,26 @@ async function importIntoExtension( } } +async function fetchCloudShare(id: string): Promise { + if (!/^[A-Za-z0-9_-]{20,32}$/u.test(id)) { + throw new Error('클라우드 공유 주소가 올바르지 않습니다.'); + } + const response = await fetch(`${CLOUD_SHARE_API}/${encodeURIComponent(id)}`); + const result = (await response.json().catch(() => null)) as ApiResult | null; + if (!response.ok) { + throw new Error( + result && !result.ok + ? result.error.message + : '클라우드 공유를 불러오지 못했습니다.', + ); + } + if (!result?.ok) { + throw new Error('클라우드 공유 응답이 올바르지 않습니다.'); + } + validateTemplateSharePayload(result.data); + return result.data; +} + export function MessagePage({ title, message }: { title: string; message: string }) { return (
@@ -51,9 +74,11 @@ export function MessagePage({ title, message }: { title: string; message: string export function SharedTemplatePage({ payload, template, + isCloudShare = false, }: { payload: TemplateSharePayloadV1; template: Template; + isCloudShare?: boolean; }) { const [status, setStatus] = useState(''); @@ -90,8 +115,9 @@ export function SharedTemplatePage({
{status &&

{status}

}

- 이 페이지는 URL의 # 뒤에 담긴 데이터를 브라우저에서만 읽습니다. 템플릿 - 내용은 GitHub Pages 서버로 전송되지 않습니다. + {isCloudShare + ? '이 큰 템플릿은 공유를 위해 Cloudflare R2에 최대 30일 동안 보관됩니다.' + : '이 페이지는 URL의 # 뒤에 담긴 데이터를 브라우저에서만 읽습니다. 템플릿 내용은 GitHub Pages 서버로 전송되지 않습니다.'}

); @@ -104,6 +130,7 @@ export function ShareApp() { template: Template; } | null>(null); const [error, setError] = useState(''); + const cloudShareId = new URLSearchParams(window.location.search).get('cloud'); useEffect(() => { const handleHashChange = () => setHash(window.location.hash); @@ -115,7 +142,10 @@ export function ShareApp() { let active = true; setSharedTemplate(null); setError(''); - void decodeTemplateSharePayload(hash) + const loadPayload = cloudShareId + ? fetchCloudShare(cloudShareId) + : decodeTemplateSharePayload(hash); + void loadPayload .then((decoded) => { if (active) { setSharedTemplate({ @@ -136,13 +166,13 @@ export function ShareApp() { return () => { active = false; }; - }, [hash]); + }, [cloudShareId, hash]); if (error) return ; if (!sharedTemplate) { return ; } - return ; + return ; } createRoot(document.getElementById('root')!).render( diff --git a/tests/forms/formValidation.test.ts b/tests/forms/formValidation.test.ts index 16de9ba..578b325 100644 --- a/tests/forms/formValidation.test.ts +++ b/tests/forms/formValidation.test.ts @@ -2,9 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - authCodeSchema, eCampusCredentialsSchema, - konkukEmailSchema, LINK_NAME_MAX_LENGTH, linkFormSchema, qrUrlSchema, @@ -68,20 +66,6 @@ test("링크 폼은 공백을 정리하고 기존 오류 문구를 유지한다" ); }); -test("건국대 이메일과 인증 코드는 형식과 도메인을 함께 검증한다", () => { - assert.deepEqual(konkukEmailSchema.safeParse(" student@konkuk.ac.kr "), { - success: true, - data: "student@konkuk.ac.kr", - }); - assert.deepEqual(konkukEmailSchema.safeParse("a..b@konkuk.ac.kr"), { - success: true, - data: "a..b@konkuk.ac.kr", - }); - assert.equal(konkukEmailSchema.safeParse("student@example.com").success, false); - assert.equal(authCodeSchema.safeParse("123456").success, true); - assert.equal(authCodeSchema.safeParse("12345a").success, false); -}); - test("Todo, eCampus, QR 입력은 소비 전에 하나의 스키마로 검증한다", () => { assert.deepEqual( todoInputSchema.safeParse({ diff --git a/tests/worker/googleIdentity.test.ts b/tests/worker/googleIdentity.test.ts new file mode 100644 index 0000000..8c5ca41 --- /dev/null +++ b/tests/worker/googleIdentity.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { verifyGoogleIdToken } from "../../worker/googleIdentity.ts"; + +function base64Url(value: Uint8Array | string): string { + const bytes = + typeof value === "string" ? new TextEncoder().encode(value) : value; + return Buffer.from(bytes).toString("base64url"); +} + +async function signedToken( + privateKey: CryptoKey, + claims: Record, +): Promise { + const header = base64Url(JSON.stringify({ alg: "RS256", kid: "test-key" })); + const payload = base64Url(JSON.stringify(claims)); + const input = `${header}.${payload}`; + const signature = await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + privateKey, + new TextEncoder().encode(input), + ); + return `${input}.${base64Url(new Uint8Array(signature))}`; +} + +test("Google ID token은 서명, audience, nonce를 모두 검증한다", async (context) => { + const keys = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ); + const jwk = await crypto.subtle.exportKey("jwk", keys.publicKey); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ keys: [{ ...jwk, kid: "test-key", alg: "RS256", use: "sig" }] }), + { headers: { "cache-control": "max-age=60" } }, + ); + context.after(() => { + globalThis.fetch = originalFetch; + }); + + const now = Math.floor(Date.now() / 1000); + const claims = { + sub: "google-user-id", + email: "user@example.com", + email_verified: true, + name: "LinKU User", + picture: "https://example.com/avatar.png", + iss: "https://accounts.google.com", + aud: "linku-client-id", + exp: now + 300, + iat: now, + nonce: "expected-nonce", + }; + const token = await signedToken(keys.privateKey, claims); + + assert.deepEqual( + await verifyGoogleIdToken( + token, + "linku-client-id", + "expected-nonce", + ), + { + sub: "google-user-id", + email: "user@example.com", + name: "LinKU User", + picture: "https://example.com/avatar.png", + }, + ); + assert.equal( + await verifyGoogleIdToken(token, "another-client", "expected-nonce"), + null, + ); + assert.equal( + await verifyGoogleIdToken(token, "linku-client-id", "wrong-nonce"), + null, + ); +}); diff --git a/web/share/index.html b/web/share/index.html index aab0f7c..cf57c22 100644 --- a/web/share/index.html +++ b/web/share/index.html @@ -6,9 +6,9 @@ - + 공유 템플릿 · LinKU diff --git a/worker/crypto.ts b/worker/crypto.ts new file mode 100644 index 0000000..d9a6dcc --- /dev/null +++ b/worker/crypto.ts @@ -0,0 +1,31 @@ +const encoder = new TextEncoder(); + +export function base64UrlEncode(value: ArrayBuffer | Uint8Array): string { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + +export function randomToken(byteLength = 32): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +} + +export async function sha256(value: string | ArrayBuffer): Promise { + const bytes = typeof value === "string" ? encoder.encode(value) : value; + return base64UrlEncode(await crypto.subtle.digest("SHA-256", bytes)); +} + +export function timingSafeEqual(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let difference = 0; + for (let index = 0; index < left.length; index += 1) { + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + return difference === 0; +} diff --git a/worker/googleIdentity.ts b/worker/googleIdentity.ts new file mode 100644 index 0000000..529192b --- /dev/null +++ b/worker/googleIdentity.ts @@ -0,0 +1,172 @@ +const GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"; +const GOOGLE_ISSUERS = new Set([ + "accounts.google.com", + "https://accounts.google.com", +]); +const CLOCK_SKEW_SECONDS = 5 * 60; + +interface GoogleJwk extends JsonWebKey { + alg?: string; + kid?: string; + use?: string; +} + +interface GoogleJwkSet { + keys: GoogleJwk[]; +} + +interface GoogleIdTokenHeader { + alg?: string; + kid?: string; +} + +export interface GoogleIdentityClaims { + sub: string; + email: string; + name?: string; + picture?: string; +} + +interface GoogleIdTokenClaims extends GoogleIdentityClaims { + aud: string | string[]; + azp?: string; + email_verified: boolean; + exp: number; + iat: number; + iss: string; + nonce: string; +} + +let cachedKeys: { expiresAt: number; keys: GoogleJwk[] } | null = null; + +function decodeBase64Url(value: string): ArrayBuffer { + const normalized = value.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes.buffer; +} + +function parseJsonPart(value: string): T | null { + try { + return JSON.parse(new TextDecoder().decode(decodeBase64Url(value))) as T; + } catch { + return null; + } +} + +function cacheTtl(response: Response): number { + const match = response.headers.get("cache-control")?.match(/max-age=(\d+)/u); + const seconds = match ? Number(match[1]) : 60 * 60; + return Math.min(Math.max(seconds, 60), 24 * 60 * 60) * 1000; +} + +async function getGoogleKeys(forceRefresh = false): Promise { + if (!forceRefresh && cachedKeys && cachedKeys.expiresAt > Date.now()) { + return cachedKeys.keys; + } + + const response = await fetch(GOOGLE_JWKS_URL); + if (!response.ok) { + throw new Error("Google signing keys are unavailable"); + } + const jwks = (await response.json()) as GoogleJwkSet; + if (!Array.isArray(jwks.keys) || jwks.keys.length === 0) { + throw new Error("Google signing keys are invalid"); + } + cachedKeys = { + expiresAt: Date.now() + cacheTtl(response), + keys: jwks.keys, + }; + return jwks.keys; +} + +async function findGoogleKey(kid: string): Promise { + const keys = await getGoogleKeys(); + const cached = keys.find((key) => key.kid === kid); + if (cached) return cached; + + // Google rotates keys. Retry once without the isolate-local cache. + const refreshed = await getGoogleKeys(true); + return refreshed.find((key) => key.kid === kid) ?? null; +} + +function hasExpectedAudience( + audience: unknown, + clientId: string, +): boolean { + if (typeof audience === "string") return audience === clientId; + return Array.isArray(audience) && + audience.every((value) => typeof value === "string") && + audience.includes(clientId); +} + +export async function verifyGoogleIdToken( + idToken: string, + clientId: string, + expectedNonce: string, +): Promise { + const [encodedHeader, encodedPayload, encodedSignature, extra] = + idToken.split("."); + if (!encodedHeader || !encodedPayload || !encodedSignature || extra) { + return null; + } + + const header = parseJsonPart(encodedHeader); + const claims = parseJsonPart(encodedPayload); + if (!header || !claims || header.alg !== "RS256" || !header.kid) { + return null; + } + + const key = await findGoogleKey(header.kid); + if (!key || (key.alg && key.alg !== "RS256") || (key.use && key.use !== "sig")) { + return null; + } + const publicKey = await crypto.subtle.importKey( + "jwk", + key, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + const validSignature = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + publicKey, + decodeBase64Url(encodedSignature), + new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`), + ); + if (!validSignature) return null; + + const now = Math.floor(Date.now() / 1000); + if ( + typeof claims.sub !== "string" || + !claims.sub || + typeof claims.email !== "string" || + !claims.email || + claims.email_verified !== true || + typeof claims.iss !== "string" || + !GOOGLE_ISSUERS.has(claims.iss) || + !hasExpectedAudience(claims.aud, clientId) || + (claims.azp !== undefined && claims.azp !== clientId) || + typeof claims.exp !== "number" || + claims.exp <= now || + typeof claims.iat !== "number" || + claims.iat > now + CLOCK_SKEW_SECONDS || + typeof claims.nonce !== "string" || + (claims.name !== undefined && typeof claims.name !== "string") || + (claims.picture !== undefined && typeof claims.picture !== "string") || + claims.nonce !== expectedNonce + ) { + return null; + } + + return { + sub: claims.sub, + email: claims.email, + name: claims.name, + picture: claims.picture, + }; +} diff --git a/worker/http.ts b/worker/http.ts new file mode 100644 index 0000000..b0132f5 --- /dev/null +++ b/worker/http.ts @@ -0,0 +1,42 @@ +import type { ApiErrorCode, ApiResult } from "../src/types/serverless"; + +export function json( + value: T, + status = 200, + headers?: HeadersInit, +): Response { + const responseHeaders = new Headers(headers); + responseHeaders.set("content-type", "application/json; charset=utf-8"); + responseHeaders.set("x-content-type-options", "nosniff"); + return new Response(JSON.stringify(value), { status, headers: responseHeaders }); +} + +export function ok(data: T, status = 200, headers?: HeadersInit): Response { + return json>({ ok: true, data }, status, headers); +} + +export function fail( + requestId: string, + code: ApiErrorCode, + message: string, + status: number, + retryable = false, +): Response { + return json>( + { ok: false, error: { code, message, retryable, requestId } }, + status, + ); +} + +export async function readJsonBody( + request: Request, + maxBytes: number, +): Promise { + const length = Number(request.headers.get("content-length") || "0"); + if (length > maxBytes) throw new RangeError("PAYLOAD_TOO_LARGE"); + const text = await request.text(); + if (new TextEncoder().encode(text).byteLength > maxBytes) { + throw new RangeError("PAYLOAD_TOO_LARGE"); + } + return JSON.parse(text) as T; +} diff --git a/worker/index.ts b/worker/index.ts new file mode 100644 index 0000000..87a6272 --- /dev/null +++ b/worker/index.ts @@ -0,0 +1,1113 @@ +import type { + AuthProfile, + AuthSessionResponse, + CloudShareRecord, + SyncIndexEntry, + SyncIndexResponse, + SyncedTemplateDocumentV1, +} from "../src/types/serverless"; +import { validateTemplateSharePayload } from "../src/utils/templateShareCodec"; +import { randomToken, sha256, timingSafeEqual } from "./crypto"; +import { verifyGoogleIdToken } from "./googleIdentity"; +import { fail, ok, readJsonBody } from "./http"; + +interface Env { + LINKU_DATA: R2Bucket; + AUTH_RATE_LIMITER: RateLimit; + PUBLIC_RATE_LIMITER: RateLimit; + USER_RATE_LIMITER: RateLimit; + API_ORIGIN: string; + WEB_ORIGIN: string; + SHARE_PAGE_URL: string; + EXTENSION_ID: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; +} + +interface OAuthState { + redirectUri: string; + clientState: string; + codeChallenge: string; + nonce: string; + expiresAt: number; +} + +interface OAuthExchange { + accountId: string; + profile: AuthProfile; + codeChallenge: string; + expiresAt: number; +} + +interface SessionRecord { + accountId: string; + sessionId: string; + accessHash: string; + accessExpiresAt: number; + refreshHash: string; + refreshExpiresAt: number; + profile: AuthProfile; + updatedAt: string; +} + +interface AccessClaims { + accountId: string; + sessionId: string; +} + +type SessionTokenKind = "a" | "r"; + +interface SessionTokenPair { + accessToken: string; + accessHash: string; + accessExpiresAt: number; + refreshToken: string; + refreshHash: string; + refreshExpiresAt: number; +} + +const ACCESS_TOKEN_SECONDS = 15 * 60; +const REFRESH_TOKEN_SECONDS = 30 * 24 * 60 * 60; +const EXCHANGE_SECONDS = 5 * 60; +const JSON_LIMIT = 256 * 1024; +const MAX_SESSIONS_PER_ACCOUNT = 5; +const MAX_TEMPLATES_PER_ACCOUNT = 50; +const MAX_CLOUD_SHARES_PER_ACCOUNT = 20; +const SHARE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const TOKEN_PART_PATTERN = /^[A-Za-z0-9_-]{22,128}$/u; + +function requestId(): string { + return crypto.randomUUID(); +} + +function requestActor(request: Request): string { + return request.headers.get("cf-connecting-ip") || "local"; +} + +async function enforceRateLimit( + limiter: RateLimit, + key: string, +): Promise { + const result = await limiter.limit({ key }); + return result.success + ? null + : fail( + requestId(), + "RATE_LIMITED", + "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.", + 429, + true, + ); +} + +function allowedOrigin(request: Request, env: Env): string | null { + const origin = request.headers.get("origin"); + if (!origin) return null; + if ( + origin === env.WEB_ORIGIN || + origin === `chrome-extension://${env.EXTENSION_ID}` + ) { + return origin; + } + return null; +} + +function addResponseHeaders( + response: Response, + request: Request, + env: Env, +): Response { + const headers = new Headers(response.headers); + const origin = allowedOrigin(request, env); + if (origin) { + headers.set("access-control-allow-origin", origin); + headers.set( + "access-control-allow-headers", + "authorization, content-type, if-match, if-none-match", + ); + headers.set( + "access-control-allow-methods", + "GET, POST, PUT, DELETE, OPTIONS", + ); + headers.set("access-control-expose-headers", "etag"); + headers.append("vary", "origin"); + } + if (!headers.has("cache-control")) headers.set("cache-control", "no-store"); + headers.set("referrer-policy", "no-referrer"); + headers.set("x-content-type-options", "nosniff"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function isAllowedRedirect(value: string, env: Env): boolean { + return value === `https://${env.EXTENSION_ID}.chromiumapp.org/`; +} + +function googleCallbackUrl(env: Env): string { + return `${env.API_ORIGIN.replace(/\/$/u, "")}/api/auth/google/callback`; +} + +function conditionalHeaders(request: Request): Headers | null { + const ifMatch = request.headers.get("if-match"); + const ifNoneMatch = request.headers.get("if-none-match"); + if (ifMatch && ifNoneMatch) return null; + if (!ifMatch && ifNoneMatch !== "*") return null; + const headers = new Headers(); + if (ifMatch) headers.set("if-match", ifMatch); + if (ifNoneMatch) headers.set("if-none-match", ifNoneMatch); + return headers; +} + +function validResourceId(value: string): boolean { + return /^[A-Za-z0-9:_-]{1,128}$/u.test(value); +} + +function validateSyncedTemplateDocument( + value: unknown, + expectedId: string, +): value is SyncedTemplateDocumentV1 { + if (!value || typeof value !== "object") return false; + const document = value as Record; + if ( + document.version !== 1 || + document.id !== expectedId || + typeof document.name !== "string" || + typeof document.height !== "number" || + typeof document.cloned !== "boolean" || + !Array.isArray(document.items) || + !Array.isArray(document.stagingItems) || + typeof document.createdAt !== "string" || + !Number.isFinite(Date.parse(document.createdAt)) || + typeof document.updatedAt !== "string" || + !Number.isFinite(Date.parse(document.updatedAt)) + ) { + return false; + } + + try { + validateTemplateSharePayload({ + version: 1, + template: { + name: document.name, + height: document.height, + items: document.items, + }, + }); + validateTemplateSharePayload({ + version: 1, + template: { + name: document.name, + height: document.height, + items: document.stagingItems, + }, + }); + return true; + } catch { + return false; + } +} + +function validateCloudShareRecord(value: unknown): value is CloudShareRecord { + if (!value || typeof value !== "object") return false; + const record = value as Record; + if ( + record.version !== 1 || + typeof record.createdAt !== "string" || + !Number.isFinite(Date.parse(record.createdAt)) || + typeof record.expiresAt !== "string" || + !Number.isFinite(Date.parse(record.expiresAt)) || + Date.parse(record.expiresAt) <= Date.parse(record.createdAt) || + typeof record.ownerId !== "string" || + !TOKEN_PART_PATTERN.test(record.ownerId) + ) { + return false; + } + try { + validateTemplateSharePayload(record.payload); + return true; + } catch { + return false; + } +} + +function parseSessionToken( + token: string, + expectedKind: SessionTokenKind, +): { accountId: string; sessionId: string; secret: string } | null { + const [kind, accountId, sessionId, secret, extra] = token.split("."); + if ( + kind !== expectedKind || + !accountId || + !sessionId || + !secret || + extra || + !TOKEN_PART_PATTERN.test(accountId) || + !TOKEN_PART_PATTERN.test(sessionId) || + !TOKEN_PART_PATTERN.test(secret) + ) { + return null; + } + return { accountId, sessionId, secret }; +} + +async function issueSessionTokens( + accountId: string, + sessionId: string, + refreshExpiresAt = Math.floor(Date.now() / 1000) + REFRESH_TOKEN_SECONDS, +): Promise { + const now = Math.floor(Date.now() / 1000); + const accessSecret = randomToken(32); + const refreshSecret = randomToken(32); + return { + accessToken: `a.${accountId}.${sessionId}.${accessSecret}`, + accessHash: await sha256(accessSecret), + accessExpiresAt: now + ACCESS_TOKEN_SECONDS, + refreshToken: `r.${accountId}.${sessionId}.${refreshSecret}`, + refreshHash: await sha256(refreshSecret), + refreshExpiresAt, + }; +} + +async function listAll(env: Env, prefix: string): Promise { + const objects: R2Object[] = []; + let cursor: string | undefined; + do { + const page = await env.LINKU_DATA.list({ + prefix, + cursor, + include: ["customMetadata"], + }); + objects.push(...page.objects); + cursor = page.truncated ? page.cursor : undefined; + } while (cursor); + return objects; +} + +async function createSession( + env: Env, + accountId: string, + profile: AuthProfile, + sessionId: string, +): Promise { + const tokens = await issueSessionTokens(accountId, sessionId); + const session: SessionRecord = { + accountId, + sessionId, + accessHash: tokens.accessHash, + accessExpiresAt: tokens.accessExpiresAt, + refreshHash: tokens.refreshHash, + refreshExpiresAt: tokens.refreshExpiresAt, + profile, + updatedAt: new Date().toISOString(), + }; + await env.LINKU_DATA.put( + `auth/sessions/${accountId}/${sessionId}.json`, + JSON.stringify(session), + { httpMetadata: { contentType: "application/json" } }, + ); + + const sessions = await listAll(env, `auth/sessions/${accountId}/`); + if (sessions.length > MAX_SESSIONS_PER_ACCOUNT) { + await env.LINKU_DATA.delete( + sessions + .sort((left, right) => right.uploaded.getTime() - left.uploaded.getTime()) + .slice(MAX_SESSIONS_PER_ACCOUNT) + .map((object) => object.key), + ); + } + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: new Date(tokens.accessExpiresAt * 1000).toISOString(), + accountId, + profile, + }; +} + +async function authenticate( + request: Request, + env: Env, +): Promise { + const authorization = request.headers.get("authorization"); + if (!authorization?.startsWith("Bearer ")) return null; + const token = parseSessionToken(authorization.slice(7), "a"); + if (!token) return null; + + const object = await env.LINKU_DATA.get( + `auth/sessions/${token.accountId}/${token.sessionId}.json`, + ); + if (!object) return null; + const session = await object.json(); + const now = Math.floor(Date.now() / 1000); + if ( + session.accountId !== token.accountId || + session.sessionId !== token.sessionId || + session.accessExpiresAt <= now || + session.refreshExpiresAt <= now || + !timingSafeEqual(session.accessHash, await sha256(token.secret)) + ) { + return null; + } + return { accountId: token.accountId, sessionId: token.sessionId }; +} + +async function handleOAuthStart(request: Request, env: Env): Promise { + const url = new URL(request.url); + const redirectUri = url.searchParams.get("redirect_uri") || ""; + const codeChallenge = url.searchParams.get("code_challenge") || ""; + const clientState = url.searchParams.get("state") || ""; + if ( + !isAllowedRedirect(redirectUri, env) || + !/^[A-Za-z0-9_-]{43,128}$/u.test(codeChallenge) || + !/^[A-Za-z0-9_-]{43,128}$/u.test(clientState) + ) { + return fail( + requestId(), + "INVALID_REQUEST", + "OAuth 요청이 올바르지 않습니다.", + 400, + ); + } + + const stateToken = randomToken(32); + const state: OAuthState = { + redirectUri, + codeChallenge, + clientState, + nonce: randomToken(32), + expiresAt: Math.floor(Date.now() / 1000) + EXCHANGE_SECONDS, + }; + await env.LINKU_DATA.put( + `auth/oauth-states/${await sha256(stateToken)}.json`, + JSON.stringify(state), + { httpMetadata: { contentType: "application/json" } }, + ); + + const google = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + google.searchParams.set("client_id", env.GOOGLE_CLIENT_ID); + google.searchParams.set("redirect_uri", googleCallbackUrl(env)); + google.searchParams.set("response_type", "code"); + google.searchParams.set("scope", "openid email profile"); + google.searchParams.set("state", stateToken); + google.searchParams.set("nonce", state.nonce); + google.searchParams.set("prompt", "select_account"); + return Response.redirect(google.toString(), 302); +} + +async function handleOAuthCallback( + request: Request, + env: Env, +): Promise { + const url = new URL(request.url); + const stateToken = url.searchParams.get("state"); + if (!stateToken || !TOKEN_PART_PATTERN.test(stateToken)) { + return fail( + requestId(), + "INVALID_REQUEST", + "OAuth 상태가 만료되었거나 올바르지 않습니다.", + 400, + ); + } + + const stateKey = `auth/oauth-states/${await sha256(stateToken)}.json`; + const stateObject = await env.LINKU_DATA.get(stateKey); + if (!stateObject) { + return fail( + requestId(), + "INVALID_REQUEST", + "OAuth 상태가 만료되었거나 올바르지 않습니다.", + 400, + ); + } + await env.LINKU_DATA.delete(stateKey); + const state = await stateObject.json(); + if ( + state.expiresAt <= Math.floor(Date.now() / 1000) || + !isAllowedRedirect(state.redirectUri, env) + ) { + return fail( + requestId(), + "INVALID_REQUEST", + "OAuth 상태가 만료되었거나 올바르지 않습니다.", + 400, + ); + } + + const redirect = new URL(state.redirectUri); + redirect.searchParams.set("state", state.clientState); + const providerError = url.searchParams.get("error"); + const code = url.searchParams.get("code"); + if (providerError || !code) { + redirect.searchParams.set("error", providerError || "access_denied"); + return Response.redirect(redirect.toString(), 302); + } + + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: googleCallbackUrl(env), + grant_type: "authorization_code", + }), + }); + if (!tokenResponse.ok) { + redirect.searchParams.set("error", "token_exchange_failed"); + return Response.redirect(redirect.toString(), 302); + } + + const tokens = (await tokenResponse.json()) as { id_token?: string }; + if (!tokens.id_token) { + redirect.searchParams.set("error", "missing_identity"); + return Response.redirect(redirect.toString(), 302); + } + + let identity; + try { + identity = await verifyGoogleIdToken( + tokens.id_token, + env.GOOGLE_CLIENT_ID, + state.nonce, + ); + } catch { + redirect.searchParams.set("error", "identity_unavailable"); + return Response.redirect(redirect.toString(), 302); + } + if (!identity) { + redirect.searchParams.set("error", "invalid_identity"); + return Response.redirect(redirect.toString(), 302); + } + + const accountId = await sha256( + `https://accounts.google.com:${identity.sub}`, + ); + const exchangeCode = randomToken(32); + const exchange: OAuthExchange = { + accountId, + profile: { + name: identity.name || "LinKU 사용자", + email: identity.email, + picture: identity.picture || "", + }, + codeChallenge: state.codeChallenge, + expiresAt: Math.floor(Date.now() / 1000) + EXCHANGE_SECONDS, + }; + await env.LINKU_DATA.put( + `auth/exchanges/${await sha256(exchangeCode)}.json`, + JSON.stringify(exchange), + { httpMetadata: { contentType: "application/json" } }, + ); + redirect.searchParams.set("code", exchangeCode); + return Response.redirect(redirect.toString(), 302); +} + +async function handleOAuthExchange( + request: Request, + env: Env, +): Promise { + const body = await readJsonBody<{ + code?: string; + verifier?: string; + deviceId?: string; + }>(request, JSON_LIMIT); + if ( + !body.code || + !TOKEN_PART_PATTERN.test(body.code) || + !body.verifier || + !/^[A-Za-z0-9_-]{43,128}$/u.test(body.verifier) || + !body.deviceId || + !validResourceId(body.deviceId) + ) { + return fail( + requestId(), + "INVALID_REQUEST", + "인증 교환 요청이 올바르지 않습니다.", + 400, + ); + } + + const key = `auth/exchanges/${await sha256(body.code)}.json`; + const object = await env.LINKU_DATA.get(key); + if (!object) { + return fail( + requestId(), + "UNAUTHENTICATED", + "인증 코드가 만료되었습니다.", + 401, + ); + } + await env.LINKU_DATA.delete(key); + const exchange = await object.json(); + if ( + exchange.expiresAt <= Math.floor(Date.now() / 1000) || + !timingSafeEqual(await sha256(body.verifier), exchange.codeChallenge) + ) { + return fail( + requestId(), + "UNAUTHENTICATED", + "인증 검증에 실패했습니다.", + 401, + ); + } + return ok( + await createSession( + env, + exchange.accountId, + exchange.profile, + body.deviceId, + ), + ); +} + +async function handleRefresh(request: Request, env: Env): Promise { + const body = await readJsonBody<{ refreshToken?: string }>(request, JSON_LIMIT); + const token = parseSessionToken(body.refreshToken || "", "r"); + if (!token) { + return fail(requestId(), "UNAUTHENTICATED", "세션이 올바르지 않습니다.", 401); + } + + const key = `auth/sessions/${token.accountId}/${token.sessionId}.json`; + const object = await env.LINKU_DATA.get(key); + if (!object) { + return fail(requestId(), "UNAUTHENTICATED", "세션이 만료되었습니다.", 401); + } + const session = await object.json(); + if ( + session.accountId !== token.accountId || + session.sessionId !== token.sessionId || + session.refreshExpiresAt <= Math.floor(Date.now() / 1000) || + !timingSafeEqual(session.refreshHash, await sha256(token.secret)) + ) { + return fail(requestId(), "UNAUTHENTICATED", "세션이 만료되었습니다.", 401); + } + + const tokens = await issueSessionTokens( + token.accountId, + token.sessionId, + session.refreshExpiresAt, + ); + const updated: SessionRecord = { + ...session, + accessHash: tokens.accessHash, + accessExpiresAt: tokens.accessExpiresAt, + refreshHash: tokens.refreshHash, + refreshExpiresAt: tokens.refreshExpiresAt, + updatedAt: new Date().toISOString(), + }; + const saved = await env.LINKU_DATA.put(key, JSON.stringify(updated), { + onlyIf: { etagMatches: object.etag }, + httpMetadata: { contentType: "application/json" }, + }); + if (!saved) { + return fail( + requestId(), + "CONFLICT", + "다른 요청에서 세션이 갱신되었습니다.", + 409, + true, + ); + } + return ok({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresAt: new Date(tokens.accessExpiresAt * 1000).toISOString(), + accountId: token.accountId, + profile: session.profile, + }); +} + +async function requireClaims( + request: Request, + env: Env, + id: string, +): Promise { + return ( + (await authenticate(request, env)) ?? + fail(id, "UNAUTHENTICATED", "로그인이 필요합니다.", 401) + ); +} + +function toIndexEntry( + object: R2Object, + prefix: string, +): SyncIndexEntry { + return { + id: object.key.slice(prefix.length, -".json".length), + etag: object.etag, + size: object.size, + uploadedAt: object.uploaded.toISOString(), + deleted: object.customMetadata?.deleted === "true" || undefined, + }; +} + +async function handleSyncIndex( + env: Env, + claims: AccessClaims, +): Promise { + const prefix = `private/${claims.accountId}/templates/`; + const allTemplates = await listAll(env, prefix); + const expired = allTemplates.filter( + (object) => + object.customMetadata?.deleted === "true" && + object.uploaded.getTime() < Date.now() - TOMBSTONE_TTL_MS, + ); + if (expired.length > 0) { + await env.LINKU_DATA.delete(expired.map((object) => object.key)); + } + const expiredKeys = new Set(expired.map((object) => object.key)); + const result: SyncIndexResponse = { + templates: allTemplates + .filter((object) => !expiredKeys.has(object.key)) + .map((object) => toIndexEntry(object, prefix)), + }; + return ok(result); +} + +async function handleTemplateResource( + request: Request, + env: Env, + claims: AccessClaims, + templateId: string, +): Promise { + if (!validResourceId(templateId)) { + return fail( + requestId(), + "INVALID_REQUEST", + "템플릿 ID가 올바르지 않습니다.", + 400, + ); + } + const key = `private/${claims.accountId}/templates/${templateId}.json`; + if (request.method === "GET") { + const object = await env.LINKU_DATA.get(key); + if (!object || object.customMetadata?.deleted === "true") { + return fail( + requestId(), + "SHARE_NOT_FOUND", + "템플릿을 찾을 수 없습니다.", + 404, + ); + } + return new Response(object.body, { + headers: { + "content-type": "application/json; charset=utf-8", + etag: `"${object.etag}"`, + }, + }); + } + + const conditions = conditionalHeaders(request); + if (!conditions) { + return fail( + requestId(), + "INVALID_REQUEST", + "조건부 저장 헤더가 필요합니다.", + 428, + ); + } + + if (request.method === "DELETE") { + const existing = await env.LINKU_DATA.head(key); + if (!existing) { + return fail( + requestId(), + "SHARE_NOT_FOUND", + "템플릿을 찾을 수 없습니다.", + 404, + ); + } + const saved = await env.LINKU_DATA.put( + key, + JSON.stringify({ deleted: true }), + { + onlyIf: conditions, + customMetadata: { deleted: "true", schemaVersion: "1" }, + httpMetadata: { contentType: "application/json" }, + }, + ); + return saved + ? ok({ deleted: true }, 200, { etag: `"${saved.etag}"` }) + : fail( + requestId(), + "CONFLICT", + "다른 기기에서 템플릿이 변경되었습니다.", + 412, + true, + ); + } + + if (request.method === "PUT") { + const existing = await env.LINKU_DATA.head(key); + if (!existing) { + const objects = await listAll( + env, + `private/${claims.accountId}/templates/`, + ); + const activeCount = objects.filter( + (object) => object.customMetadata?.deleted !== "true", + ).length; + if (activeCount >= MAX_TEMPLATES_PER_ACCOUNT) { + return fail( + requestId(), + "RATE_LIMITED", + "계정에는 템플릿을 최대 50개까지 동기화할 수 있습니다.", + 429, + ); + } + } + + const document = await readJsonBody(request, JSON_LIMIT); + if (!validateSyncedTemplateDocument(document, templateId)) { + return fail( + requestId(), + "INVALID_REQUEST", + "템플릿 데이터가 올바르지 않습니다.", + 400, + ); + } + const saved = await env.LINKU_DATA.put(key, JSON.stringify(document), { + onlyIf: conditions, + customMetadata: { deleted: "false", schemaVersion: "1" }, + httpMetadata: { contentType: "application/json" }, + }); + return saved + ? ok({ etag: saved.etag }, 200, { etag: `"${saved.etag}"` }) + : fail( + requestId(), + "CONFLICT", + "다른 기기에서 템플릿이 변경되었습니다.", + 412, + true, + ); + } + + return fail( + requestId(), + "INVALID_REQUEST", + "지원하지 않는 요청입니다.", + 405, + ); +} + +async function handleCreateShare( + request: Request, + env: Env, + claims: AccessClaims, +): Promise { + const payload = await readJsonBody(request, JSON_LIMIT); + try { + validateTemplateSharePayload(payload); + } catch { + return fail( + requestId(), + "INVALID_REQUEST", + "공유 템플릿이 올바르지 않습니다.", + 400, + ); + } + + const shareId = randomToken(16); + const now = new Date(); + const record: CloudShareRecord = { + version: 1, + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + SHARE_TTL_MS).toISOString(), + ownerId: claims.accountId, + payload, + }; + const indexPrefix = `private/${claims.accountId}/share-index/`; + const existing = await listAll(env, indexPrefix); + if (existing.length >= MAX_CLOUD_SHARES_PER_ACCOUNT) { + const stale = existing + .sort((left, right) => left.uploaded.getTime() - right.uploaded.getTime()) + .slice(0, existing.length - MAX_CLOUD_SHARES_PER_ACCOUNT + 1); + await Promise.all( + stale.flatMap((object) => { + const staleId = object.key.slice(indexPrefix.length, -".json".length); + return [ + env.LINKU_DATA.delete(object.key), + env.LINKU_DATA.delete(`public/shares/${staleId}.json`), + ]; + }), + ); + } + + await Promise.all([ + env.LINKU_DATA.put( + `public/shares/${shareId}.json`, + JSON.stringify(record), + { + httpMetadata: { + contentType: "application/json", + cacheControl: "public, max-age=300", + }, + }, + ), + env.LINKU_DATA.put( + `${indexPrefix}${shareId}.json`, + JSON.stringify({ createdAt: record.createdAt, expiresAt: record.expiresAt }), + { httpMetadata: { contentType: "application/json" } }, + ), + ]); + + const shareUrl = new URL(env.SHARE_PAGE_URL); + shareUrl.searchParams.set("cloud", shareId); + return ok({ id: shareId, url: shareUrl.toString() }, 201); +} + +async function handlePublicShare( + request: Request, + env: Env, + shareId: string, +): Promise { + if (!/^[A-Za-z0-9_-]{20,32}$/u.test(shareId)) { + return fail( + requestId(), + "SHARE_NOT_FOUND", + "공유를 찾을 수 없습니다.", + 404, + ); + } + const key = `public/shares/${shareId}.json`; + if (request.method === "GET") { + const object = await env.LINKU_DATA.get(key); + if (!object) { + return fail( + requestId(), + "SHARE_NOT_FOUND", + "공유가 삭제되었거나 존재하지 않습니다.", + 404, + ); + } + const record: unknown = await object.json(); + if (!validateCloudShareRecord(record)) { + await env.LINKU_DATA.delete(key); + return fail( + requestId(), + "SHARE_NOT_FOUND", + "공유 데이터가 손상되었습니다.", + 404, + ); + } + if (Date.parse(record.expiresAt) <= Date.now()) { + await env.LINKU_DATA.delete(key); + return fail( + requestId(), + "SHARE_NOT_FOUND", + "공유가 만료되었습니다.", + 404, + ); + } + return ok(record.payload, 200, { + "cache-control": "public, max-age=300", + etag: `"${object.etag}"`, + }); + } + + const claims = await authenticate(request, env); + if (!claims) { + return fail(requestId(), "UNAUTHENTICATED", "로그인이 필요합니다.", 401); + } + const object = await env.LINKU_DATA.get(key); + if (!object) { + return fail( + requestId(), + "SHARE_NOT_FOUND", + "공유를 찾을 수 없습니다.", + 404, + ); + } + const record: unknown = await object.json(); + if (!validateCloudShareRecord(record)) { + await env.LINKU_DATA.delete(key); + return fail( + requestId(), + "SHARE_NOT_FOUND", + "공유 데이터가 손상되었습니다.", + 404, + ); + } + if (record.ownerId !== claims.accountId) { + return fail( + requestId(), + "FORBIDDEN", + "공유를 삭제할 권한이 없습니다.", + 403, + ); + } + await Promise.all([ + env.LINKU_DATA.delete(key), + env.LINKU_DATA.delete( + `private/${claims.accountId}/share-index/${shareId}.json`, + ), + ]); + return ok({ deleted: true }); +} + +async function deletePrefix(env: Env, prefix: string): Promise { + const objects = await listAll(env, prefix); + for (let index = 0; index < objects.length; index += 1_000) { + await env.LINKU_DATA.delete( + objects.slice(index, index + 1_000).map((object) => object.key), + ); + } +} + +async function deleteAccountData(env: Env, accountId: string): Promise { + const indexPrefix = `private/${accountId}/share-index/`; + const shares = await listAll(env, indexPrefix); + await Promise.all( + shares.map((object) => { + const shareId = object.key.slice(indexPrefix.length, -".json".length); + return env.LINKU_DATA.delete(`public/shares/${shareId}.json`); + }), + ); + await Promise.all([ + deletePrefix(env, `private/${accountId}/`), + deletePrefix(env, `auth/sessions/${accountId}/`), + ]); +} + +async function route(request: Request, env: Env): Promise { + const url = new URL(request.url); + const path = url.pathname; + if (!path.startsWith("/api/")) { + return fail(requestId(), "INVALID_REQUEST", "API 경로를 찾을 수 없습니다.", 404); + } + + const requestOrigin = request.headers.get("origin"); + if (requestOrigin && !allowedOrigin(request, env)) { + return fail(requestId(), "FORBIDDEN", "허용되지 않은 출처입니다.", 403); + } + if (request.method === "OPTIONS") return new Response(null, { status: 204 }); + if (path === "/api/health" && request.method === "GET") { + return ok({ status: "ok" }); + } + + if ( + path === "/api/auth/google/start" || + path === "/api/auth/google/callback" || + path === "/api/auth/exchange" || + path === "/api/auth/refresh" + ) { + const limited = await enforceRateLimit( + env.AUTH_RATE_LIMITER, + `${path}:${requestActor(request)}`, + ); + if (limited) return limited; + } + if (path === "/api/auth/google/start" && request.method === "GET") { + return handleOAuthStart(request, env); + } + if (path === "/api/auth/google/callback" && request.method === "GET") { + return handleOAuthCallback(request, env); + } + if (path === "/api/auth/exchange" && request.method === "POST") { + return handleOAuthExchange(request, env); + } + if (path === "/api/auth/refresh" && request.method === "POST") { + return handleRefresh(request, env); + } + + const shareMatch = path.match(/^\/api\/shares\/v1\/([A-Za-z0-9_-]+)$/u); + if (shareMatch && (request.method === "GET" || request.method === "DELETE")) { + const limited = await enforceRateLimit( + env.PUBLIC_RATE_LIMITER, + `${request.method}:${requestActor(request)}`, + ); + if (limited) return limited; + return handlePublicShare(request, env, shareMatch[1]); + } + + const actorLimited = await enforceRateLimit( + env.USER_RATE_LIMITER, + `ip:${requestActor(request)}`, + ); + if (actorLimited) return actorLimited; + + const id = requestId(); + const claimsOrResponse = await requireClaims(request, env, id); + if (claimsOrResponse instanceof Response) return claimsOrResponse; + const claims = claimsOrResponse; + const limited = await enforceRateLimit( + env.USER_RATE_LIMITER, + `account:${claims.accountId}`, + ); + if (limited) return limited; + + if (path === "/api/auth/logout" && request.method === "POST") { + await env.LINKU_DATA.delete( + `auth/sessions/${claims.accountId}/${claims.sessionId}.json`, + ); + return ok({ loggedOut: true }); + } + if (path === "/api/account" && request.method === "DELETE") { + await deleteAccountData(env, claims.accountId); + return ok({ deleted: true }); + } + if (path === "/api/sync/v1/index" && request.method === "GET") { + return handleSyncIndex(env, claims); + } + const templateMatch = path.match( + /^\/api\/sync\/v1\/templates\/([^/]+)$/u, + ); + if (templateMatch) { + return handleTemplateResource( + request, + env, + claims, + decodeURIComponent(templateMatch[1]), + ); + } + if (path === "/api/shares/v1" && request.method === "POST") { + return handleCreateShare(request, env, claims); + } + return fail(id, "INVALID_REQUEST", "API 경로를 찾을 수 없습니다.", 404); +} + +export default { + async fetch(request: Request, env: Env): Promise { + try { + return addResponseHeaders(await route(request, env), request, env); + } catch (error) { + const id = requestId(); + if (error instanceof RangeError && error.message === "PAYLOAD_TOO_LARGE") { + return addResponseHeaders( + fail(id, "PAYLOAD_TOO_LARGE", "요청 데이터가 너무 큽니다.", 413), + request, + env, + ); + } + if (error instanceof SyntaxError) { + return addResponseHeaders( + fail(id, "INVALID_REQUEST", "JSON 요청이 올바르지 않습니다.", 400), + request, + env, + ); + } + // eslint-disable-next-line no-console -- request ID is needed for Worker log correlation. + console.error("Worker request failed", { + requestId: id, + path: new URL(request.url).pathname, + }); + return addResponseHeaders( + fail( + id, + "UPSTREAM_UNAVAILABLE", + "일시적인 서버 오류가 발생했습니다.", + 500, + true, + ), + request, + env, + ); + } + }, +} satisfies ExportedHandler; diff --git a/worker/tsconfig.json b/worker/tsconfig.json new file mode 100644 index 0000000..496ab90 --- /dev/null +++ b/worker/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "paths": { "@/*": ["../src/*"] }, + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["@cloudflare/workers-types"] + }, + "include": [ + "./**/*.ts", + "../src/types/serverless.ts", + "../src/types/templateShare.ts", + "../src/utils/templateShareCodec.ts" + ] +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..0cd56de --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,47 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "linku-api", + "main": "./worker/index.ts", + "compatibility_date": "2026-08-11", + "routes": [ + { + "pattern": "linku.turtlehwan.dev", + "custom_domain": true + } + ], + "r2_buckets": [ + { + "binding": "LINKU_DATA", + "bucket_name": "linku-data", + "preview_bucket_name": "linku-data-preview" + } + ], + "ratelimits": [ + { + "name": "AUTH_RATE_LIMITER", + "namespace_id": "149001", + "simple": { "limit": 20, "period": 60 } + }, + { + "name": "PUBLIC_RATE_LIMITER", + "namespace_id": "149002", + "simple": { "limit": 120, "period": 60 } + }, + { + "name": "USER_RATE_LIMITER", + "namespace_id": "149003", + "simple": { "limit": 120, "period": 60 } + } + ], + "vars": { + "API_ORIGIN": "https://linku.turtlehwan.dev", + "WEB_ORIGIN": "https://turtle-hwan.github.io", + "SHARE_PAGE_URL": "https://turtle-hwan.github.io/LinKU/share/", + "EXTENSION_ID": "fmfbhmifnohhfiblebbdjlioppfppbgh" + }, + "observability": { + "enabled": true, + "head_sampling_rate": 0.1 + }, + "limits": { "cpu_ms": 10 } +}