diff --git a/.env.development.example b/.env.development.example index dd81fc85..1012f418 100644 --- a/.env.development.example +++ b/.env.development.example @@ -22,8 +22,9 @@ VITE_ENVIRONMENT=development # GA Measurement Protocol API secret VITE_GA_API_SECRET= -# 우리 Backend 주소 -VITE_API_BASE_URL=https://this-is-linku-backend.example/api +# Supabase CLI의 `supabase status` 출력에서 복사합니다. +VITE_SUPABASE_URL=http://127.0.0.1:54321 +VITE_SUPABASE_PUBLISHABLE_KEY=your_local_publishable_key # VoC Google Apps Script Web App의 /exec URL VITE_VOC_ENDPOINT=https://script.google.com/macros/s/your_deployment_id/exec diff --git a/.github/workflows/deploy-gh-pages.yml b/.github/workflows/deploy-gh-pages.yml index 25a21da5..70a99ec5 100644 --- a/.github/workflows/deploy-gh-pages.yml +++ b/.github/workflows/deploy-gh-pages.yml @@ -30,7 +30,6 @@ jobs: - name: Build for GitHub Pages env: - VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} VITE_VOC_ENDPOINT: ${{ vars.VITE_VOC_ENDPOINT }} run: pnpm run build:gh-pages diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 81285bf8..127f85cb 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -25,26 +25,16 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm run lint + - name: Build project env: - VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} VITE_VOC_ENDPOINT: ${{ vars.VITE_VOC_ENDPOINT }} run: pnpm run build:local - - name: Test monitoring boundaries - run: pnpm run test:monitoring - - - name: Test analytics transport - run: pnpm run test:analytics - - - name: Verify production Sentry bundle metadata - run: pnpm run test:sentry-bundle - - - name: Test stateless template sharing - run: pnpm run test:template-share - - - name: Build GitHub Pages share viewer - run: pnpm run build:gh-pages - - - name: Build success - run: echo "✅ Build completed successfully!" + - name: Test local-first and monitoring contracts + run: | + pnpm run test:monitoring + pnpm run test:sentry-bundle + pnpm run test:templates diff --git a/.github/workflows/upload-chrome-extension-draft.yml b/.github/workflows/upload-chrome-extension-draft.yml index 02e88605..b8db6b0c 100644 --- a/.github/workflows/upload-chrome-extension-draft.yml +++ b/.github/workflows/upload-chrome-extension-draft.yml @@ -64,6 +64,25 @@ jobs: exit 1 fi + - name: Validate Supabase public configuration + env: + SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }} + SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }} + shell: bash + run: | + if [ -z "$SUPABASE_URL" ] || [ -z "$SUPABASE_PUBLISHABLE_KEY" ]; then + echo "::error::Configure VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY" + exit 1 + fi + + curl --fail --silent --show-error --max-time 10 \ + "${SUPABASE_URL%/}/rest/v1/rpc/browse_publications" \ + -H "apikey: $SUPABASE_PUBLISHABLE_KEY" \ + -H "Authorization: Bearer $SUPABASE_PUBLISHABLE_KEY" \ + -H "Content-Type: application/json" \ + --data '{"p_query":"","p_sort":"latest","p_offset":0,"p_limit":1}' \ + --output /dev/null + - name: Setup pnpm uses: pnpm/action-setup@v6 @@ -81,9 +100,11 @@ jobs: run: | node scripts/updateVersion.js VERSION=$(node -p "require('./public/manifest.json').version") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" - echo "sentry_release=linku@$VERSION" >> "$GITHUB_OUTPUT" + { + echo "version=$VERSION" + echo "tag=v$VERSION" + echo "sentry_release=linku@$VERSION" + } >> "$GITHUB_OUTPUT" - name: Configure direct analytics transport env: @@ -100,7 +121,8 @@ jobs: - name: Build extension env: - VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }} + VITE_SUPABASE_URL: ${{ vars.VITE_SUPABASE_URL }} + VITE_SUPABASE_PUBLISHABLE_KEY: ${{ vars.VITE_SUPABASE_PUBLISHABLE_KEY }} VITE_VOC_ENDPOINT: ${{ vars.VITE_VOC_ENDPOINT }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: production diff --git a/AGENTS.md b/AGENTS.md index 94dc7397..37f63fa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,12 @@ LinKU는 건국대학교 학생을 위한 Manifest V3 Chrome Extension입니다. 팝업 UI에서 학교 및 학생 서비스 링크, 공지, todo, banner, template 편집과 -공유, 도서관 좌석 현황, QR 생성 같은 Labs 기능을 제공합니다. +게시, 도서관 좌석 현황, QR 생성 같은 Labs 기능을 제공합니다. -이 저장소는 프론트엔드 확장 프로그램 코드만 포함합니다. LinKU backend와의 -통신은 `VITE_API_BASE_URL`을 기준으로 이루어지며, 학교 및 외부 사이트 접근은 -`public/manifest.json`의 `host_permissions`가 제어합니다. +이 저장소는 확장 프로그램과 Supabase schema를 함께 포함합니다. 계정 동기화와 +커뮤니티는 `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`를 사용하고, +학교 및 외부 사이트 접근은 `public/manifest.json`의 `host_permissions`가 +제어합니다. ## 읽는 순서 @@ -41,6 +42,7 @@ pnpm install pnpm run dev pnpm run build:local pnpm run lint +pnpm run test:templates pnpm run test:timetable ``` @@ -89,8 +91,10 @@ Chrome에서 확장 프로그램을 검증하기 전에는 `pnpm run build:local - `src/layouts/`: route layout wrapper. - `src/contexts/`: React Context 기반 상태 container. - `src/hooks/`: feature 단위 hook. -- `src/apis/`: LinKU backend API wrapper. +- `src/apis/supabase/`: Auth, account sync와 community adapter. - `src/apis/external/`: 학교 또는 외부 서비스 연동. +- `src/storage/`: IndexedDB schema와 feature별 repository. +- `src/sync/`: 로컬 template과 cloud document 변환. - `src/background/`: Manifest V3 service worker와 message handling. - `src/utils/`: storage, auth, analytics, template, Chrome helper utility. - `src/types/`: 공유 TypeScript data contract. @@ -112,12 +116,14 @@ Chrome에서 확장 프로그램을 검증하기 전에는 `pnpm run build:local 병합하는 side-effect 없는 도메인 로직을 담당합니다. - `src/utils/timetableStorage.ts`: snapshot asset과 별도 override index의 저장, schema migration, 삭제 시 정리를 담당합니다. -- `src/apis/client.ts`: auth interceptor, backend response parsing, - silent reauth를 담당합니다. +- `src/apis/supabase/client.ts`: publishable configuration, PKCE session과 + `chrome.storage.local` adapter를 담당합니다. - `src/apis/external/`: third-party 또는 school page markup에 의존하는 parsing logic이 있습니다. -- `src/utils/templateStorage.ts`: local draft persistence와 migration risk가 - 있습니다. +- `src/storage/templates/repository.ts`: local template persistence와 migration + risk가 있습니다. +- `src/storage/account/syncRepository.ts`: outbox race와 account binding을 + 담당합니다. ## 검증 기준 diff --git a/README.md b/README.md index f4adb686..54e40ed9 100644 --- a/README.md +++ b/README.md @@ -1,276 +1,48 @@ -# LinKU :: 건국대학교 학생들을 위한 교내외 관련 페이지 모음 크롬 확장 프로그램 +# LinKU -> [Chrome extension Link](https://chromewebstore.google.com/detail/linku/fmfbhmifnohhfiblebbdjlioppfppbgh?hl=ko&utm_source=ext_sidebar) -> [GitHub Pages](https://turtle-hwan.github.io/LinKU) +건국대학교 학생들이 자주 쓰는 교내외 서비스, 공지, Todo, 시간표와 직접 만든 +바로가기 템플릿을 한곳에서 사용하는 Manifest V3 Chrome Extension입니다. -> [!note] -> 자주 접속하는 학교 관련 여러 사이트들을 검색하기도 힘들고, 일일이 찾기도 힘들어 한 번에 모아주는 확장 프로그램을 만들어 봤어요. -> -> 부족한 점이나 이런 기능 추가되면 좋겠다는 의견이 있다면 Issue에 남겨주세요! -> 시간 날 때마다 추가해볼게요. +- [Chrome Web Store](https://chromewebstore.google.com/detail/linku/fmfbhmifnohhfiblebbdjlioppfppbgh?hl=ko) +- [GitHub Pages](https://turtle-hwan.github.io/LinKU) -> [!tip] -> Link + KU 라는 이름에 알맞게 건국대 공식 사이트들 뿐만 아니라, 건대 학생들이 직접 만들거나 창업한 서비스도 연결해보려 생각 중이에요. -> -> 추가로 아시는 정보가 있다면 언제든 기여 부탁드려요! +개인 템플릿은 Chrome IndexedDB에 먼저 저장됩니다. Google 로그인은 선택 사항이며, +로그인한 경우 Supabase를 통해 여러 기기 동기화와 커뮤니티 게시 기능을 사용할 수 +있습니다. 네트워크나 Supabase가 unavailable이어도 로컬 편집·적용·백업은 계속 +동작합니다. -### Students Made Services of Konkuk University +## 시작하기 -- **[ku-ring :: 건국대학교 공지 알리미, 쿠링]** [링크](https://github.com/ku-ring) - - Android, iOS 앱 제공 -- **[PlayKUround :: 캠퍼스 안의 작은 놀이터, 플레이쿠라운드]** [링크](https://github.com/playkuround) - - Android, iOS 앱 제공 -- **[KUstaurant :: 건대 맛집 탐색 쿠스토랑]** [링크](https://kustaurant.com/) - - Web, App 제공 -- **[언제볼까 :: 빠른 모임 날짜 약속]** [링크](https://when-will-we-meet.com/) - - Web 제공 -- **[쿠맵 :: 건국대학교 배리어프리 지도]** [링크](https://github.com/KU-Barrier-Free/) - - Android, iOS 앱 제공 - -## Preview - -![image](https://github.com/user-attachments/assets/86e3cc34-4aac-4d04-8ce9-053afa0232d8) - -### 키보드 단축키 - -LinKU 확장 프로그램을 빠르게 열 수 있는 단축키: - -- **Windows/Linux**: `Ctrl + Shift + L` -- **Mac**: `Command + Shift + L` - -> [!tip] -> 단축키는 `chrome://extensions/shortcuts`에서 사용자 정의할 수 있습니다. - -## Skills - -- Chrome extension -- Vite + React + TypeScript -- tailwindcss + shad/cn - -## How to Contribute - -### 개발 환경 요구사항 - -- Node.js 24 LTS -- pnpm +요구사항은 Node.js 24와 pnpm입니다. ```bash git clone https://github.com/Turtle-Hwan/LinKU.git cd LinKU pnpm install -``` - -### 실행 방법 - -```bash -# 개발 서버 (Hot reload, console 로그 O, 버전 고정) -pnpm run dev - -# 로컬 빌드 테스트 (console 로그 O, 버전 고정) +cp .env.development.example .env.development pnpm run build:local -# → dist 폴더를 chrome://extensions에 로드 - -# 프로덕션 빌드 (console 로그 X, 버전 자동 증가) -pnpm run build ``` -- react 환경으로 구성되어 있어 dev로 실행되는 화면이 그대로 적용됩니다. -- 로컬에서 extension에 적용하려면, build 후 dist 폴더를 chrome extension에서 불러오면 확인할 수 있습니다. - -- 현재 dist 폴더는 extension 배포 용도로 배너 이미지를 제외하고 빌드합니다. - - gh-pages는 extension에서 배너 이미지를 불러오기 위해 /banners 경로에 배너 이미지들과 정보가 담긴 banners.json을 함께 빌드합니다. - - 코드 수정 시 auto rebuild를 원한다면, --watch 옵션을 붙이거나 `pnpm run watch` 를 사용하면 됩니다. - -```js -//dist 폴더에 배너 이미지 제외하고 빌드 -"build": "node scripts/updateVersion.js && tsc -b && vite build --mode production", - -//지속 재빌드 -"watch": "node scripts/updateVersion.js && tsc -b && vite build --watch --mode production", - -//gh-pages 폴더에 배너 이미지 포함하여 빌드 -"build:gh-pages": "tsc -b && vite build --mode gh-pages", -``` - -
-환경 변수 설정 (Google Analytics) - -Google Analytics는 background worker에서 GA4 Measurement Protocol endpoint로 직접 -전송합니다: +생성된 `dist/`를 `chrome://extensions`의 개발자 모드에서 "압축해제된 확장 +프로그램을 로드합니다"로 선택합니다. `pnpm run dev`는 React UI 반복 작업용이며 +`chrome.identity`, background service worker와 실제 extension storage를 검증하지 +않습니다. ```bash -# .env.development 파일 생성 -cp .env.development.example .env.development - -# .env.development 파일에서 VITE_GA_API_SECRET을 설정 +pnpm run lint +pnpm run test:templates +pnpm run test:timetable +pnpm run build:gh-pages ``` -운영 workflow는 `VITE_GA_API_SECRET`이 없으면 GA가 빠진 release를 만들지 않도록 -실패합니다. direct 전송 특성상 API secret은 extension bundle에서 확인할 수 있으며, -스팸 이벤트로 리포트가 오염될 수 있는 위험을 수용합니다. 광고 차단기나 오프라인으로 -전송하지 못한 이벤트는 재시도하지 않고 버리며, 확장 프로그램 기능과 Sentry에는 -영향을 주지 않습니다. - -운영 전송 계약과 fallback 정책은 -[`docs/GA4-Data-Taxonomy.md`](docs/GA4-Data-Taxonomy.md)를 참고하세요. - -
- -
-Chrome Extension 자동 배포 설정 (For Maintainers) - -이 프로젝트는 `main` 브랜치에 push/merge 시 Chrome Web Store에 자동으로 draft를 업로드하는 GitHub Actions workflow를 사용합니다. - -### 동작 방식 - -- **자동화**: main 브랜치에 코드가 merge될 때마다 자동으로 빌드 후 Chrome Web Store에 draft 업로드 -- **수동 심사**: draft 업로드만 자동화되며, 실제 심사 제출은 [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)에서 수동으로 진행 -- **충돌 방지**: 심사 중일 때 새 커밋이 발생해도 draft만 업데이트되므로 심사 충돌 없음 - -### 1단계: Google Cloud Console 설정 - -#### 1.1 프로젝트 생성 - -1. [Google Cloud Console](https://console.cloud.google.com/)에 접속 -2. 상단의 프로젝트 선택 드롭다운 클릭 -3. "새 프로젝트" 선택 -4. 프로젝트 이름 입력 (예: `LinKU Chrome Extension`) -5. "만들기" 클릭 - -#### 1.2 Chrome Web Store API 활성화 - -1. 좌측 메뉴에서 **"API 및 서비스" > "라이브러리"** 선택 -2. 검색창에 `Chrome Web Store API` 입력 -3. "Chrome Web Store API" 클릭 -4. **"사용"** 버튼 클릭 - - ⚠️ 이 단계를 건너뛰면 나중에 API 호출 시 오류 발생! - -#### 1.3 OAuth 동의 화면 설정 - -1. 좌측 메뉴에서 **"API 및 서비스" > "OAuth 동의 화면"** 선택 -2. **User Type: "External"** 선택 후 "만들기" 클릭 -3. **앱 정보** 입력: - - 앱 이름: 임의로 입력 (예: `LinKU Upload`) - - 사용자 지원 이메일: 본인 이메일 - - 개발자 연락처 정보: 본인 이메일 -4. "저장 후 계속" 클릭 -5. **범위** 페이지: 그냥 "저장 후 계속" 클릭 (범위는 나중에 CLI에서 자동 설정됨) -6. **테스트 사용자** 페이지: **⚠️ 매우 중요!** - - **"ADD USERS"** 버튼 클릭 - - 본인 Gmail 주소 입력 (예: `your-email@gmail.com`) - - "추가" 클릭 - - **이 단계를 건너뛰면 "액세스 차단됨" 오류 발생!** -7. "저장 후 계속" 클릭 - -#### 1.4 OAuth 클라이언트 ID 생성 - -1. 좌측 메뉴에서 **"API 및 서비스" > "사용자 인증 정보"** 선택 -2. 상단의 **"+ 사용자 인증 정보 만들기"** 클릭 -3. **"OAuth 클라이언트 ID"** 선택 -4. 설정: - - **애플리케이션 유형: "데스크톱 앱"** - - 이름: 임의로 입력 (예: `Chrome Webstore Upload`) -5. "만들기" 클릭 -6. 생성된 **Client ID**와 **Client Secret**을 복사하여 안전한 곳에 보관 - -### 2단계: OAuth Refresh Token 발급 - -#### 2.1 CLI 도구 실행 - -터미널에서 다음 명령어 실행: - -```bash -npx chrome-webstore-upload-keys -``` - -#### 2.2 인증 정보 입력 - -CLI가 다음을 차례로 요청합니다: - -1. **Client ID** 입력 (1.4 단계에서 복사한 값 붙여넣기) -2. **Client Secret** 입력 (1.4 단계에서 복사한 값 붙여넣기) -3. **Extension ID** 입력 (아래 참고) - -**Extension ID 찾는 방법:** - -- [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)에서 확장 프로그램 선택 -- URL의 마지막 부분이 Extension ID입니다 -- 예: `https://chrome.google.com/webstore/devconsole/.../fmfbhmifnohhfiblebbdjlioppfppbgh` - → Extension ID: `fmfbhmifnohhfiblebbdjlioppfppbgh` - -#### 2.3 브라우저 OAuth 인증 - -1. CLI가 자동으로 브라우저를 열고 Google 인증 페이지로 이동 -2. **Google 계정 선택** (1.3.6에서 추가한 테스트 사용자 계정) -3. **"앱이 확인되지 않음"** 경고가 나타날 수 있음: - - **"고급"** 클릭 - - **"[앱 이름](안전하지 않음)으로 이동"** 클릭 - - ✅ 본인이 만든 앱이므로 안전합니다! -4. **권한 승인**: - - "Chrome Web Store에 액세스" 권한 확인 - - **"허용"** 클릭 -5. 인증 완료 후 터미널로 돌아가서 **Refresh Token** 확인 및 복사 - -**⚠️ "액세스 차단됨: 앱이 테스트 중" 오류 발생 시:** - -- 1.3.6 단계에서 테스트 사용자를 추가하지 않았거나 -- 다른 Google 계정으로 로그인한 경우 -- → Google Cloud Console로 돌아가서 테스트 사용자 추가 후 다시 시도 - -### 3단계: GitHub Secrets 설정 - -#### 3.1 GitHub Repository Settings 접속 - -1. GitHub 저장소 페이지 접속 -2. 상단 메뉴에서 **"Settings"** 클릭 -3. 좌측 메뉴에서 **"Secrets and variables" > "Actions"** 선택 - -#### 3.2 Secrets 추가 - -**"New repository secret"** 버튼을 클릭하여 다음 4개의 secret을 차례로 추가: - -| Name | Value | -| ---------------------- | --------------------------------------- | -| `CHROME_EXTENSION_ID` | Extension ID (2.2에서 확인한 값) | -| `CHROME_CLIENT_ID` | OAuth Client ID (1.4에서 복사한 값) | -| `CHROME_CLIENT_SECRET` | OAuth Client Secret (1.4에서 복사한 값) | -| `CHROME_REFRESH_TOKEN` | Refresh Token (2.3에서 복사한 값) | - -**각 secret 추가 방법:** - -1. **Name** 필드에 위 표의 이름 입력 (대소문자 정확히) -2. **Secret** 필드에 해당 값 붙여넣기 -3. **"Add secret"** 클릭 -4. 4개 모두 추가될 때까지 반복 - -Sentry release와 source map 업로드에 필요한 별도 Secret/Variable은 -[`docs/OBSERVABILITY.md`](docs/OBSERVABILITY.md)의 설정 표를 따릅니다. - -### 4단계: 배포 확인 및 심사 제출 - -#### 4.1 자동 배포 확인 - -1. `main` 브랜치에 코드 push/merge -2. GitHub 저장소의 **"Actions"** 탭에서 workflow 실행 확인 -3. "Upload Chrome Extension Draft" workflow가 성공적으로 완료되면 ✅ - -#### 4.2 수동 심사 제출 - -1. [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole) 접속 -2. 확장 프로그램 선택 -3. 좌측 메뉴에서 **"패키지"** 탭 확인 -4. 새로 업로드된 draft 버전 확인 -5. **"심사 제출"** 버튼 클릭 -6. 심사 완료까지 대기 (보통 24시간~3일 소요) - -**💡 Tip:** - -- 심사 중일 때 새 커밋이 발생해도 draft만 업데이트되므로 안전 -- 심사 완료 후 Developer Dashboard에서 수동으로 배포 가능 +Supabase 로컬 스키마와 계정 기능 개발 방법은 +[기여 가이드](docs/CONTRIBUTING.md)를 참고하세요. -
+## 문서 -## Special Thanks +- [Architecture](docs/ARCHITECTURE.md): 런타임, 저장소와 데이터 흐름 +- [Local-first](docs/LOCAL_FIRST.md): 로컬 저장·동기화·충돌·게시 계약 +- [Contributing](docs/CONTRIBUTING.md): 개발 환경과 검증 기준 +- [Observability](docs/OBSERVABILITY.md): Sentry 경계와 개인정보 정책 -- Logos designed by [pm_doyoo](https://www.instagram.com/pm_doyoo/) -- Cozy coding zone provided by [makers farm](https://www.instagram.com/makersfarm_konkuk) aka [lion](https://www.instagram.com/00_minwooky) +기능 제안이나 오류 제보는 GitHub Issue에 남겨 주세요. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d191c06..8ce718de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,152 +1,116 @@ # 아키텍처 -이 문서는 LinKU의 런타임 경계와 데이터 흐름만 설명합니다. 실행·기여 규칙은 -`docs/CONTRIBUTING.md`, 에이전트 진입점은 `AGENTS.md`를 따릅니다. +LinKU는 Vite, React, TypeScript로 만든 Manifest V3 Chrome Extension입니다. ## 런타임 -LinKU는 Vite, React, TypeScript로 만든 Manifest V3 Chrome Extension이며 세 -영역으로 실행됩니다. +- Popup/extension page: `index.html` → `src/main.tsx` → `src/routes.tsx` +- Background service worker: `src/background/index.ts` +- Everytime content script: `src/content/everytime-timetable.ts` +- Static site: `web/index.html` -- Popup UI: `index.html` → `src/main.tsx` → `src/routes.tsx`. -- Background service worker: `src/background/index.ts`. -- Everytime content script: `src/content/everytime-timetable.ts`. - -popup은 화면과 사용자 입력을 담당하고, OAuth·badge·시간표 import처럼 extension -API가 필요한 작업은 background worker가 담당합니다. content script는 -`https://everytime.kr/timetable*`에서만 실행됩니다. - -extension build는 다음 entry를 `dist/`에 생성합니다. - -- `index.html` -- `background/index.js` -- `content/everytime-timetable.js` - -로컬 검증에는 version을 올리지 않는 `pnpm run build:local`을 사용합니다. +popup은 화면과 사용자 입력을 담당합니다. OAuth, badge, 시간표 import처럼 Chrome +extension API가 필요한 작업은 background가 담당합니다. content script는 +`https://everytime.kr/timetable*`의 시간표 읽기에만 사용합니다. ## 소스 경계 -- `src/pages/`, `src/layouts/`: route와 layout. -- `src/components/Tabs/`: popup feature. -- `src/components/ui/`: 공통 UI primitive. -- `src/components/Editor/`: template editor. -- `src/contexts/`, `src/hooks/`: React state와 reusable hook. -- `src/storage/`: IndexedDB schema, record normalization과 repository primitive. -- `src/apis/`: 현재 연결된 LinKU backend client. 템플릿·아이콘 로컬 저장은 포함하지 않음. -- `src/apis/external/`: 학교·외부 서비스 연동. -- `src/background/`: MV3 service worker와 message handler. -- `src/content/`: 허용된 외부 페이지 content script. -- `src/types/`, `src/utils/`: 공유 contract와 cross-cutting utility. +- `src/pages/`, `src/layouts/`: route와 layout +- `src/components/`: feature UI와 공통 UI primitive +- `src/contexts/`, `src/hooks/`: 화면 상태와 side effect 연결 +- `src/storage/indexedDb/`: IndexedDB schema와 version upgrade +- `src/storage/templates/`: 템플릿·아이콘·백업 repository +- `src/storage/account/`: 동기화 outbox, 계정 binding과 sync metadata +- `src/sync/`: 로컬 모델과 클라우드 문서 codec +- `src/apis/supabase/`: Auth, Postgres RPC/RLS와 Storage adapter +- `src/apis/external/`: 학교·외부 서비스의 공개 연동 +- `src/background/`: MV3 message handler와 OAuth orchestration +- `src/types/`, `src/utils/`: 공유 contract와 cross-cutting utility -## 주요 데이터 흐름 +UI는 IndexedDB나 SQL을 직접 다루지 않습니다. 로컬 작업은 storage repository, +원격 작업은 Supabase adapter, 두 계층의 순서와 충돌 처리는 sync service가 담당합니다. -### 개인 템플릿과 공유 +## 템플릿과 계정 동기화 -개인 템플릿 CRUD는 LinKU backend와 분리되어 있습니다. popup과 editor는 -`src/utils/templateStorage.ts`의 저장소 경계만 사용하고, 실제 템플릿은 `linku` -IndexedDB에 저장합니다. `src/storage/legacyTemplateStorage.ts`가 이전 저장소 이관을, -`src/storage/templateIconRepair.ts`가 읽기 시 아이콘 복구를 각각 맡습니다. 사용자가 -올린 아이콘도 256px 이하 WebP로 정규화한 뒤 같은 DB의 별도 store에 저장하며 화면은 -`src/utils/localIcons.ts`의 명시적인 로컬 작업만 호출합니다. `drafts` store는 이전 -버전의 draft를 잃지 않도록 보관하지만 현재 편집 흐름에는 연결하지 않습니다. +```text +Editor save + → IndexedDB templates + outbox (same transaction) + → success UI + → optional background sync + → Supabase RPC with expected revision +``` -기존 `localStorage` 템플릿과 draft는 popup이 처음 저장소를 열 때 한 번 -IndexedDB로 복사합니다. 이전 값은 한 릴리즈 동안 rollback 원본으로 남기므로 -마이그레이션 실패가 기존 데이터 삭제로 이어지지 않습니다. +템플릿과 사용자 아이콘은 항상 이 기기에 먼저 저장됩니다. 첫 Google 로그인 때 현재 +로컬 항목을 outbox에 넣고 이후 여러 기기와 동기화합니다. 네트워크 실패는 outbox에 +남으며 로컬 성공을 되돌리지 않습니다. -작은 템플릿 공유 링크는 압축한 payload를 GitHub Pages URL의 fragment(`#`)에 -담습니다. fragment는 HTTP 요청에 포함되지 않으며 Pages의 `/share/` 화면에서만 -검증·해제됩니다. URL 제한을 넘는 템플릿은 서버에 자동 업로드하지 않고 -`.linku.json` 파일로 내보냅니다. Pages에서 확장 프로그램으로 가져오는 외부 -메시지는 manifest와 background 양쪽에서 LinKU share 경로로 제한합니다. +템플릿에는 로컬 UI용 숫자 `templateId`와 동기화용 UUID `id`가 있습니다. 숫자 ID는 +IndexedDB transaction에서 발급하고, UUID는 계정 간 데이터 키로 사용합니다. 계정이 +섞이지 않도록 한 Chrome profile의 로컬 저장소는 최초 연결한 Supabase user ID에 +고정됩니다. -계정 로그인, 여러 기기 동기화, 충돌 처리와 cloud share는 이 로컬 저장소 위에 -별도 계층으로 추가하며, 로컬 저장 성공 여부와 분리해야 합니다. 상세 경계는 -`docs/LOCAL_FIRST.md`를 참고합니다. +Postgres의 `revision`으로 optimistic concurrency를 검사합니다. 같은 템플릿을 두 +기기에서 수정하면 원격본을 원래 항목에 적용하고 아직 동기화되지 않은 로컬본은 +독립적인 `(충돌 복사본)`으로 보존합니다. 비정상 JSON, 원본 bytes나 사용자 ID는 +Sentry로 보내지 않습니다. -### 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`에 저장합니다. +게시물은 원본 템플릿과 분리된 수동 snapshot입니다. -feature API는 이 client와 background 경계를 재사용해야 합니다. auth code, -token, authorization header를 로그에 남기지 않습니다. +- 원본을 편집해도 게시물은 자동 변경되지 않습니다. +- 변경된 원본은 `업데이트 필요`로 표시하며 사용자가 게시물 업데이트를 선택합니다. +- 업데이트는 같은 publication ID, 좋아요와 복제 수를 유지합니다. +- 게시 중인 원본은 게시를 내리기 전 삭제할 수 없습니다. +- 복제본은 새 로컬 숫자 ID와 UUID를 가진 독립 템플릿입니다. -### Everytime 시간표 +갤러리 조회·검색·복제는 익명으로 사용할 수 있고 게시·좋아요·닉네임 변경은 Google +로그인이 필요합니다. 검색과 정렬은 `browse_publications` RPC가 안전한 공개 필드만 +반환합니다. Google email, 이름, 사진과 내부 owner ID는 공개 응답에 포함하지 않습니다. -```text -Popup - → Background import handler - → 로그인된 Everytime 탭 재사용 또는 임시 탭 생성 - → Content script의 학기·시간표 XML API - → API 실패 시 렌더링된 DOM fallback - → 구조화 snapshot 저장 -``` +## Supabase 보안 경계 -가져오기는 사용자가 요청할 때만 실행됩니다. 현재 학사 시기의 네 학기부터 -탐색하고, 묶음 전체가 비어 있으면 이전 묶음으로 이동합니다. 수동 동기화는 새 -학기를 추가하고 같은 학기의 snapshot만 갱신하며, 다른 학기·업로드 이미지·active -선택은 유지합니다. +- Chrome에는 Supabase URL과 publishable key만 포함합니다. +- Google client ID/secret, service-role key는 extension과 저장소에 넣지 않습니다. +- OAuth는 background의 `chrome.identity.launchWebAuthFlow`와 PKCE를 사용합니다. +- session은 `chrome.storage.local`의 trusted extension context에만 저장합니다. +- 사용자별 row와 object path는 RLS/Storage policy로 격리합니다. +- account RPC와 policy는 signed JWT의 Google provider를 다시 검사합니다. +- template document와 WebP asset은 client와 database 양쪽에서 크기·형식을 제한합니다. -원본 snapshot과 사용자 override는 별도 저장하고 조회 시 병합합니다. 현재 -popup에는 override 편집 UI가 없지만 저장 경계는 원본을 덮지 않도록 분리되어 -있습니다. LinKU는 Everytime password, cookie, session token을 읽거나 저장하지 +`supabase/migrations/`가 schema의 단일 진실 원천이고 `src/types/supabase.ts`는 그 +schema의 TypeScript contract입니다. Edge Function, Worker, Realtime과 cron은 사용하지 않습니다. -### 공개 공지 - -공개 공지는 학교 RSS와 HTML source별로 `chrome.storage.local`에 캐시합니다. -화면 진입 시 필요한 source만 갱신하고, 실패하면 기존 캐시를 유지합니다. -popup이 닫힌 동안 background polling은 실행하지 않습니다. - -### 배너 - -popup의 기존 배너 요청은 background service worker가 가로채 CacheStorage의 마지막 -정상 JSON·이미지 snapshot을 먼저 반환합니다. 하루에 한 번 새 JSON과 참조 이미지가 -모두 준비된 경우에만 snapshot을 교체하며, 실패하면 기존 snapshot을 유지합니다. -배너 운영 기간은 캐시 시점이 아니라 popup을 열 때의 현재 시각으로 판정합니다. - -## Storage - -- `chrome.storage.local`: auth, 설정, todo, badge, 공지 캐시, 배너 재검사 시각, - 시간표 metadata와 snapshot/override. -- CacheStorage: 마지막으로 검증된 배너 JSON·이미지 snapshot. -- IndexedDB `linku`: 개인 template, legacy draft, 사용자 icon blob, 손상 record 격리. -- `localStorage`: non-extension 시간표 fallback과 이전 template/draft의 1회 - 마이그레이션 원본. 새 template 데이터는 쓰지 않습니다. -- IndexedDB: 사용자가 직접 올린 시간표 이미지 blob. +## 기타 데이터 흐름 -`chrome.storage.local`에는 token과 eCampus 인증정보도 들어가므로 background가 시작될 때 -access level을 `TRUSTED_CONTEXTS`로 제한합니다. Everytime content script는 이 저장소를 -사용하지 않습니다. 이 보안 경계를 제공하는 `setAccessLevel()`에 맞춰 manifest의 최소 -Chrome 버전은 102이며, runtime feature detection은 unpacked·mock 환경의 방어선으로 -유지합니다. +Everytime 시간표는 로그인된 탭에서 사용자가 명시적으로 요청할 때만 읽습니다. 원본 +snapshot, 사용자 override와 업로드 이미지는 분리해 저장하며 password, cookie, +session token은 읽거나 저장하지 않습니다. -시간표 metadata의 read-modify-write는 Web Locks로 popup과 background 사이에서 -직렬화합니다. 저장 shape를 변경할 때는 기존 schema migration과 사용자 데이터 -보존을 함께 구현해야 합니다. +공개 공지는 학교 RSS/HTML source를 직접 읽어 `chrome.storage.local`에 source별로 +캐시합니다. 갱신 실패 시 마지막 cache를 유지하고 background polling이나 개인 학과 +구독은 사용하지 않습니다. -## UI 구성 +배너는 background CacheStorage의 마지막 정상 JSON·이미지 snapshot을 먼저 반환하고, +새 snapshot이 완전히 준비된 경우에만 교체합니다. -UI는 Tailwind CSS, shadcn-style Radix primitive, Lucide icon을 사용합니다. -공통 primitive를 우선 재사용합니다. 하나의 feature가 loading·empty·saved·dialog -같은 여러 역할로 구성되면 compound component로 조합하되, 단일 역할 component를 -불필요하게 감싸지 않습니다. +## 저장소 지도 -## 권한과 배포 +- IndexedDB `linku`: template, legacy draft, user icon blob, outbox, sync metadata, + settings, quarantine +- `chrome.storage.local`: Supabase session, UI 설정, Todo, 시간표 metadata, + 공지 cache +- CacheStorage: 검증된 배너 snapshot +- Supabase Postgres: profile, template document, publication, like +- Supabase Storage: private user icon과 게시용 public icon -Chrome permission과 `host_permissions`는 `public/manifest.json`에서 관리합니다. -새 권한은 필요한 domain과 API로 최소화하고, 변경 시 보안 경계와 실제 extension -검증 방법을 PR에 기록합니다. +전체 로컬 템플릿과 참조 아이콘은 `linku-backup-*.json`으로 내보내고 복원할 수 +있습니다. 단일 템플릿 URL/file 직접 공유는 제공하지 않습니다. -PR은 `.github/workflows/pr-build-check.yml`에서 build를 검증합니다. main의 release -workflow가 manifest version, Chrome Web Store draft, GitHub Release와 Pages 배포를 -관리하므로 일반 PR에서 `public/manifest.json` version을 직접 수정하지 않습니다. +## 빌드와 배포 -Sentry 관측성은 `docs/OBSERVABILITY.md`에 정리합니다. popup, background service -worker, Everytime content script는 공통 초기화 정책을 사용하며, content script는 -standalone classic script로 별도 빌드합니다. production release는 `linku@` release에 source map을 업로드한 뒤 확장 프로그램 zip에서 source map을 -제거합니다. +`pnpm run build:local`은 version을 바꾸지 않고 extension과 content script를 +빌드합니다. `main` workflow만 manifest version, Chrome Web Store draft, GitHub +Release와 정적 Pages 배포를 관리합니다. 일반 PR에서 manifest version을 직접 +수정하지 않습니다. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 51661cc6..b6110fbb 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,131 +1,111 @@ # 기여 가이드 -LinKU는 실제 사용자가 설치하는 Chrome Extension입니다. 변경 범위를 작게 유지하고 -사용자 데이터, permission, 인증, release 흐름에 미치는 영향을 명확히 드러내세요. +LinKU는 실제 사용자가 설치하는 Chrome Extension입니다. 사용자 데이터, permission, +인증과 release에 미치는 영향을 작고 검증 가능한 변경으로 제출해 주세요. -## 시작하기 +## 개발 환경 ```bash pnpm install +cp .env.development.example .env.development pnpm run dev ``` -`pnpm run dev`는 React UI 확인용입니다. Chrome extension runtime 검증에는 다음 -명령으로 만든 `dist/`를 `chrome://extensions`에서 로드합니다. +`pnpm run dev`는 React UI 반복 작업용입니다. MV3 service worker, `chrome.identity`, +extension storage가 관련된 변경은 반드시 빌드된 확장에서 확인합니다. ```bash pnpm run build:local +# dist/를 chrome://extensions에서 unpacked extension으로 로드 ``` -MV3 service worker와 popup을 자동 smoke test하려면 Playwright Chromium을 한 번 -설치한 뒤 전용 명령을 실행합니다. 이 테스트는 임시 브라우저 프로필에 최신 -`dist/`를 직접 로드하므로 사용자의 Chrome 프로필과 설치된 확장을 변경하지 않습니다. -실행 비용과 로컬 Chrome 의존성을 고려해 PR CI에는 연결하지 않고 개발자가 필요할 때 -로컬에서 실행합니다. +## Supabase 로컬 개발 + +Docker가 실행 중인 상태에서 다음 명령으로 Postgres/Auth/Storage를 시작합니다. ```bash -pnpm exec playwright install --no-shell chromium -pnpm run test:extension +pnpm exec supabase start +pnpm exec supabase status +pnpm exec supabase db reset +pnpm exec supabase db lint --level warning +pnpm exec supabase test db +pnpm exec supabase stop ``` -`tests/extension/extension.fixture.ts`는 임시 Chromium에 확장을 로드하고 MV3 -background worker를 찾은 뒤 context를 정리하는 일만 담당합니다. -`tests/extension/smoke.spec.ts`는 action popup이 열리고 React root가 렌더링되는지만 -확인합니다. 기능별 runtime 검증은 `tests/extension/features/*.spec.ts`에 추가합니다. -예를 들어 배너 기능 테스트는 오프라인에서도 마지막 snapshot이 즉시 표시되며 실패한 -갱신이 기존 캐시를 지우지 않는지 독립적으로 검증합니다. `test:extension`은 로딩 -smoke와 등록된 기능별 spec을 모두 실행합니다. 특정 spec이나 headed 실행이 필요할 -때는 package script를 늘리지 않고 Playwright 경로나 `LINKU_E2E_HEADED=1` 환경 변수를 -사용합니다. - -Chrome Web Store 설치본이나 실제 OAuth 계정처럼 사용자 Chrome 상태에 의존하는 -흐름은 별도 수동 검증 대상으로 유지합니다. - -backend 기능에는 유효한 `VITE_API_BASE_URL`이 필요합니다. 실제 secret은 commit하지 -마세요. - -## 작업 원칙 - -- 하나의 branch와 PR은 하나의 목적에 집중합니다. -- 기존 working tree의 사용자 변경과 저장된 데이터의 하위 호환성을 보존합니다. -- route, feature, external API, background 책임은 `docs/ARCHITECTURE.md`의 소스 - 경계를 따릅니다. -- 외부 API·DOM parser는 수집 범위와 fallback을 명확히 하고 실패를 사용자 데이터 - 손실로 이어지게 하지 않습니다. -- feature PR에서 새로운 state library, router, styling system, formatter, test - framework를 함께 도입하지 않습니다. -- loading·empty·saved·dialog처럼 하나의 feature 화면이 여러 역할로 나뉘면 - compound component를 우선 검토합니다. - -커밋은 가능한 한 작은 단위로 나누고 Conventional Commits 형식을 권장합니다. - -```text -feat: add user-facing behavior -fix(auth): handle expired token -refactor(editor): split canvas helpers -docs: update contributor guide -``` +`supabase status`의 API URL과 publishable key를 `.env.development`의 +`VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY`에 넣습니다. 두 값은 공개 client +설정이며 service-role key를 사용하면 안 됩니다. + +DB/RLS/Storage policy 테스트에는 Google credential이 필요하지 않습니다. 실제 OAuth를 +검증할 때만 Supabase Auth에 Google provider를 켜고 로컬 unpacked extension의 +`https://.chromiumapp.org/supabase` redirect URL을 allowlist에 추가합니다. +Google client ID/secret은 로컬 ignored environment 또는 Supabase provider 설정에만 +두고 `VITE_` 변수, source, fixture나 문서에 값을 기록하지 않습니다. +Google Cloud의 Authorized redirect URI에는 chromiumapp URL이 아니라 Supabase Dashboard가 +표시하는 `/auth/v1/callback` URL을 등록합니다. + +운영 Supabase에도 email/phone/anonymous signup은 끄고 Google provider만 활성화해야 +합니다. Google nonce 검증은 끄지 않습니다. DB의 RLS/RPC도 JWT의 Google provider를 +검사하지만 provider 설정은 배포 전 수동 gate입니다. + +## 변경 원칙 + +- IndexedDB 쓰기 성공과 원격 동기화 결과를 분리합니다. +- schema 변경은 기존 store와 record를 보존하는 additive upgrade로 작성합니다. +- SQL schema 변경은 migration, generated TypeScript type와 pgTAP을 함께 갱신합니다. +- public gallery 응답에 email, Google profile, owner ID나 private document를 넣지 않습니다. +- permission과 `host_permissions`는 필요한 범위보다 넓히지 않습니다. +- access/refresh token, auth code, PKCE verifier, secret, cookie, template JSON과 icon + bytes를 로그로 남기지 않습니다. +- 예상 가능한 offline, conflict, validation과 RLS 결과는 toast/breadcrumb로 처리하고 + 예상 밖 contract/storage 오류만 한 경계에서 Sentry에 수집합니다. +- 새 상태 관리·UI·test framework는 feature 변경과 함께 도입하지 않습니다. ## 검증 -모든 code change는 최소한 다음을 통과해야 합니다. +모든 code change는 최소 다음 명령을 실행합니다. ```bash +pnpm run lint pnpm run build:local ``` -TypeScript, React hook, shared utility를 수정했다면 lint를 실행합니다. 시간표 도메인 -로직을 수정했다면 전용 회귀 테스트도 실행합니다. +변경 영역에 따라 관련 테스트를 추가합니다. ```bash -pnpm run lint +pnpm run test:templates pnpm run test:timetable -pnpm run test:template-share +pnpm run test:alerts +pnpm run test:monitoring +pnpm run build:gh-pages +pnpm exec supabase test db ``` -변경 유형별 추가 확인: - -- UI: 실제 popup 크기에서 layout, keyboard, loading/error 상태. -- Background, storage, OAuth, badge: 빌드된 unpacked extension. -- 외부 사이트 parser: 실제 페이지·응답과 fallback. 로그인 정보나 원문 응답을 - 로그 또는 fixture에 남기지 않습니다. -- Permission: 추가된 API/domain이 최소 범위인지 확인합니다. -- Template share: codec test와 `pnpm run build:gh-pages`를 함께 실행하고, - fragment가 네트워크 요청에 포함되지 않는지 확인합니다. -- 배너 운영 기간: `startAt`/`endAt`에 timezone이 포함된 ISO 8601 값을 사용하고, - 즉시 내려야 하는 배너는 이전 확장도 고려해 목록에서 제거합니다. +빌드된 MV3 runtime smoke test는 임시 Chromium profile을 사용합니다. -테스트하지 못한 범위와 기존 실패는 PR 설명에 명시합니다. - -## PR 체크리스트 - -- 변경 목적과 사용자 영향 요약. -- 실행한 검증 명령과 수동 확인 결과. -- UI 변경의 screenshot 또는 GIF. -- backend·외부 응답 shape 가정. -- permission 변경 사유와 실제 extension 검증 방법. -- migration 또는 사용자 데이터 보존 영향. +```bash +pnpm exec playwright install --no-shell chromium +pnpm run test:extension +``` -## 보안과 로깅 +실제 Google 계정 선택과 운영 Supabase RLS는 local/mock 테스트와 구분해 PR에 기록합니다. +테스트하지 못한 범위와 기존 실패를 숨기지 마세요. -- `host_permissions`는 domain 단위로 최소화하고 ``를 새로 사용하지 - 않습니다. -- access/refresh token, auth code, secret, authorization header, cookie, private - user data를 로그에 남기지 않습니다. -- `console.*` 대신 `src/utils/logger.ts`를 사용하고 production에는 필요한 - warn/error만 남깁니다. -- 외부 응답 전체보다 상태 코드와 비민감 핵심 필드만 기록합니다. +PR CI는 매 변경에 필요한 lint, extension build, local-first template과 monitoring 계약만 +검사합니다. Chromium 설치가 필요한 MV3 Playwright, Docker 기반 Supabase pgTAP과 전체 +기능 회귀는 관련 변경에서 로컬로 실행하고 결과를 PR에 기록합니다. GitHub Pages는 실제 +배포 workflow에서 다시 빌드하므로 일반 PR에서 중복 빌드하지 않습니다. -## 릴리즈와 문서 +## PR과 릴리즈 -일반 PR에서 `public/manifest.json` version을 직접 수정하지 않습니다. main의 -workflow가 Chrome Web Store draft, GitHub Release, Pages 배포와 version bump를 -담당합니다. +커밋은 가장 작은 coherent unit으로 나누고 PR에는 사용자 영향, migration, permission, +검증 결과를 적습니다. UI 변경은 popup 크기의 screenshot이나 GIF를 첨부합니다. -- `README.md`: 제품 소개와 빠른 시작. -- `docs/ARCHITECTURE.md`: 런타임 경계와 데이터 흐름. -- `docs/CONTRIBUTING.md`: 작업·검증·PR 규칙. -- `AGENTS.md`: 코딩 에이전트 진입점. +일반 PR에서 `public/manifest.json` version을 수정하지 않습니다. `main` workflow가 +Chrome Web Store draft, GitHub Release, Pages 배포와 version bump를 담당합니다. -architecture, permission, workflow 또는 onboarding이 바뀌면 관련 문서만 같은 PR에서 -갱신합니다. +- `README.md`: 제품 소개와 빠른 시작 +- `docs/ARCHITECTURE.md`: 런타임과 데이터 경계 +- `docs/LOCAL_FIRST.md`: 저장·동기화 계약 +- `docs/OBSERVABILITY.md`: Sentry 정책 +- `AGENTS.md`: 코딩 에이전트 진입점 diff --git a/docs/GA4-Data-Taxonomy.md b/docs/GA4-Data-Taxonomy.md index 146ad8ae..cfc01f25 100644 --- a/docs/GA4-Data-Taxonomy.md +++ b/docs/GA4-Data-Taxonomy.md @@ -128,8 +128,6 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | `auth_login_success` | 구현됨 | 실제 로그인 성공률 측정 | `provider`, `is_guest` | 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 @@ -168,8 +166,7 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | Event Name | 상태 | 목적 | 주요 Params | 우선순위 | | --- | --- | --- | --- | --- | | `alerts_view_open` | 구현됨 | 공지 탭 사용 여부 | `view_mode`(`all`\|`my`), `category` | P2 | -| `alerts_item_open` | 구현됨 | 공지 클릭률 | `alert_id`, `category`, `source`(`general`\|`department`) | P2 | -| `alerts_subscription_change` | 구현됨 | 개인화 기능 사용 | `category`, `result` | P3 | +| `alerts_item_open` | 구현됨 | 공지 클릭률 | `alert_id`, `category`, `source`(=`general`) | P2 | | `todo_view_open` | 구현됨 | Todo 기능 사용 여부 | `todo_count` | P2 | | `todo_item_create` | 구현됨 | Todo 입력 | `source`, `has_due_date` | P2 | | `todo_item_complete` | 구현됨 | Todo 완료율 | `item_type`(`custom`\|`ecampus`) | P2 | @@ -207,7 +204,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 사용 | @@ -225,7 +221,6 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | `template_name_edit` | **제거** | P3 — 분석 활용도 낮음, save_success에 함축 | | `alerts_view_open` | `MP_alerts_view` | prefix + _view 진입 컨벤션 | | `alerts_item_open` | `MP_alertsItem_open` | prefix + camelCase object | -| `alerts_subscription_change` | `MP_alertsSubscription_update` | prefix + _change 레거시 전용 규칙 | | `todo_view_open` | `MP_todo_view` | prefix + _view 진입 컨벤션 | | `todo_item_create/complete/delete` | `MP_todoItem_create/complete/delete` | prefix + camelCase object | | `labs_view_open` | `MP_labs_open` | prefix + _open 다이얼로그 컨벤션 | @@ -246,7 +241,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` | 구현됨 | @@ -259,7 +253,7 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | Session-based return cohort | `extension_session_start` | | Core action retention | `extension_first_open` cohort + `link_open` return condition | | Local template funnel | `template_editor_open` → `template_item_add` → `template_save_success` → `template_apply` | -| Auth funnel | `auth_login_start` → `auth_login_success` → `auth_email_verification_success` | +| Auth funnel | `auth_login_start` → `auth_login_success` | ## Non-Goals @@ -298,9 +292,6 @@ LinKU의 가장 기본 가치인 "교내외 링크를 빠르게 연다"를 측 | `navigation_tab_view` | 다이얼로그 탭 노출 | 실제로 표시된 실험실·설정 탭 측정 | `feature_area`, `tab_name`, `ui_location`, `view_source` | `usePersistentDialogTab.ts` | 기본값·복원·사용자 선택을 구분 | | `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`) | `MyAlertsView.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` | - | diff --git a/docs/LOCAL_FIRST.md b/docs/LOCAL_FIRST.md index d5589db1..a5ec886e 100644 --- a/docs/LOCAL_FIRST.md +++ b/docs/LOCAL_FIRST.md @@ -1,120 +1,76 @@ -# Local-first 경계 +# Local-first 계정 동기화 계약 -LinKU의 개인화 기능은 서버가 없어도 먼저 동작하고, 계정 기능은 그 위에 선택적으로 -붙이는 구조를 사용합니다. 이 문서는 stateless 기반과 후속 stateful 계층 사이의 -계약을 설명합니다. +LinKU의 로컬 저장이 제품의 기본 경로이고 Supabase 계정 동기화는 선택적인 두 번째 +계층입니다. -## Stateless 기반 +## 장애 시 보장 -현재 기반에서 서버 없이 완결되는 기능은 다음과 같습니다. - -| 데이터/기능 | 저장 또는 전달 위치 | 서버 장애 시 동작 | +| 기능 | 로컬 저장 | Supabase 장애 시 | | --- | --- | --- | -| 개인 템플릿 | Chrome IndexedDB `linku/templates` | 생성·조회·수정·삭제 가능 | -| 편집 draft | Chrome IndexedDB `linku/drafts` | 레거시 draft 1회 이관 보관 | -| 사용자 아이콘 | Chrome IndexedDB `linku/assets` | 업로드·목록·템플릿 적용 가능 | -| 손상 레코드 | Chrome IndexedDB `linku/quarantine` | 원본 보존, 파일로 내보내기 | -| 전체 백업 | `linku-backup-*.json` 파일 | 내보내기·복원 가능 | -| 적용 중인 템플릿 ID | `chrome.storage.local` | popup 재실행 후 유지 | -| 작은 템플릿 공유 | GitHub Pages URL fragment | 서버 저장 없이 미리보기·가져오기 가능 | -| 큰 템플릿 공유 | `.linku.json` 파일 | 파일 전달로 내보내기·가져오기 가능 | - -`drafts` store는 레거시 `localStorage` draft를 잃지 않도록 이관해 보관하는 -호환 슬롯입니다. 에디터의 자동 draft 저장과 관리 UI는 아직 연결되어 있지 -않습니다. 다른 화면에서 `templateId === 0`은 번들 기본 템플릿을 뜻하므로 draft를 -그 값으로 지칭하지 않습니다. - -기본 CRUD 화면은 `saveLocalTemplate`, `getLocalTemplate`, -`listLocalTemplates`, `deleteLocalTemplate`을 사용하고, 가져오기·공유·백업 화면도 -같은 `templateStorage` 경계를 거칩니다. 모든 읽기와 쓰기는 비동기 IndexedDB -작업입니다. 과거 `localStorage` 값은 -`local-storage-templates-v1` migration이 완료되기 전에 복사하며, migration 완료 -기록과 데이터 저장을 같은 transaction에서 처리합니다. 완료 기록에는 원본별 -fingerprint와 처리 결과를 남겨, 새 runtime에서 rollback 중 수정되거나 추가된 값만 -다시 이관합니다. 이관 transaction이 실패하면 목록·백업 등 현재 작업도 실패하므로 -불완전한 결과를 성공으로 표시하지 않습니다. rollback을 위해 원본 값은 남겨 두되, -사용자가 IndexedDB에서 템플릿을 삭제하면 같은 legacy 항목도 함께 삭제하여 다음 -migration에서 되살아나지 않게 합니다. - -## 공유 보안 경계 - -- URL payload는 gzip 후 base64url로 인코딩하며 `#v1.` 뒤에 둡니다. -- payload는 template 1개, item 최대 36개, 6×6 grid, HTTP(S) 링크만 허용합니다. -- 압축 해제 결과와 파일은 256KB 이하만 처리합니다. -- 실행 가능한 SVG data URL은 받지 않고 PNG, JPEG, WebP base64만 허용합니다. -- 외부 URL 아이콘은 내보낼 때 기본 링크 아이콘으로 바꾸고, 가져올 때는 거부해 - 미리보기만으로 제3자 서버에 요청하지 않게 합니다. -- Pages의 외부 extension message는 - `https://turtle-hwan.github.io/LinKU/share/`에서만 받습니다. -- Pages에서 보낸 가져오기 요청은 service worker가 `chrome.storage.local` queue에 - 최대 5개까지 보관하고, popup이 열릴 때 검증 후 IndexedDB에 저장합니다. queue가 - 가득 차면 기존 요청을 버리지 않고 새 요청을 명시적으로 거부합니다. -- Pages viewer는 `connect-src 'none'` CSP를 사용하므로 템플릿 fragment와 오류를 - Sentry를 포함한 외부 서버로 보내지 않습니다. - -## 로컬 데이터 무결성 - -서버 사본도 원격 점검 수단도 없으므로 저장소 계층이 다음을 스스로 보장합니다. - -- **식별자 발급**: `templateId`는 쓰기와 같은 transaction 안에서 사용 중인 최대 - 값보다 크게 발급합니다. 시계가 뒤로 가도 기존 템플릿을 덮어쓰지 않습니다. - 호출부는 `templateId: 0`을 넘기고 저장소가 부여한 값을 돌려받습니다. -- **읽기 정규화**: 모든 레코드는 읽는 시점에 `normalizeStoredTemplate`을 거칩니다. - 격자를 벗어난 좌표, 중복된 항목 식별자, 빠진 시각은 보정하고 그 사실을 기록합니다. -- **격리**: 보정으로 살릴 수 없는 레코드는 삭제하지 않고 `quarantine` store로 - 원본 그대로 옮긴 뒤 개수를 사용자에게 알립니다. 현재 UI에서는 복구용 파일로 - 내려받을 수 있으며 자동 삭제하지 않습니다. -- **아이콘 재등록**: 인라인 이미지를 가진 항목의 `iconId`가 asset에 없거나 같은 - 숫자가 다른 이미지를 가리키면 실제 이미지로 다시 등록해 올바른 양수 id를 - 부여합니다. 등록되지 않은 아이콘을 가진 항목은 `linkFormSchema`가 거부해 - 이름·주소·위치까지 저장할 수 없게 되기 때문입니다. -- **저장 공간**: 확장 저장소는 best-effort 모드로 둡니다. 사용자의 "인터넷 사용 - 기록 삭제"는 확장 저장소를 지우지 않지만, 디스크 압박 시 브라우저가 이 출처의 - 데이터를 통째로 축출할 수는 있습니다. 드문 경우이고 계정 동기화가 붙으면 - 유일본 조건 자체가 사라지므로, `unlimitedStorage` 권한으로 면제받는 대신 백업 - 파일을 복구 경로로 둡니다. 저장 실패는 할당량 초과와 그 밖의 오류를 구분해 - 안내합니다. -- **백업**: 템플릿과 아이콘 전체를 한 파일로 내보내고 10MB 이하 파일만 복원합니다. - 내보내기에도 같은 10MB 제한을 적용해 현재 버전이 다시 읽지 못하는 파일을 성공한 - 백업처럼 내려받지 않으며, 초과하면 정리할 항목을 사용자에게 안내합니다. - 파일 envelope와 아이콘 형식을 저장소 작업 전에 검증하고, 복원한 asset의 실제 - id로 모든 아이콘 참조를 다시 연결합니다. 로컬 숫자 id와 계정 동기화에 쓰일 - UUID를 모두 새로 발급하므로 기존 로컬·원격 템플릿을 덮어쓰지 않습니다. 이미 - 정규화된 백업 아이콘은 다시 인코딩하지 않고 원래 bytes를 보존해, 같은 백업을 - 반복 복원해도 content hash가 같은 asset을 재사용합니다. - -이 PR은 `linku` IndexedDB를 처음 배포하므로 stateless store 전체가 초기 v1 schema에 -들어갑니다. 이 PR이 배포된 뒤 DB schema를 바꿀 때부터 version을 올리고 `upgrade`에서 -반드시 `oldVersion`을 분기합니다. 가드 없는 `createObjectStore`는 이미 이전 버전이 -깔린 사용자 기기에서만 실패하며, 그 실패는 우리 쪽에서 복구할 수 없습니다. 기존 -popup이나 service worker가 연결을 잡고 있으면 `blocking` callback이 연결을 닫아 다음 -버전의 upgrade가 멈추지 않게 합니다. - -## 기존 서버 데이터의 릴리스 경계 - -이 기반은 현재 기기의 `localStorage` 템플릿만 IndexedDB로 옮깁니다. 다른 기기에서 -만들었거나 복제해 서버에만 남은 템플릿은 이 migration의 입력이 아니며, 서버 데이터 -자체를 삭제하거나 변경하지도 않습니다. - -`main` merge는 Chrome Web Store에 새 draft를 올리지만 실제 심사 제출은 수동입니다. -서버 전용 템플릿을 계정 로그인 후 가져오는 후속 동기화나 검증된 일회성 내보내기 -경로가 준비되기 전에는 이 local-first draft를 스토어 심사에 제출하지 않습니다. 이는 -후속 경로가 준비될 때까지 `main`의 다른 변경도 포함해 스토어 릴리스를 동결한다는 -뜻입니다. - -## 후속 stateful 계층의 계약 - -계정 동기화 PR은 다음 원칙을 지켜 이 기반 위에 추가합니다. - -1. IndexedDB 저장은 항상 먼저 완료하고 성공 UI를 반환합니다. -2. 동기화는 durable outbox로 별도 수행하며 네트워크 실패가 로컬 저장을 rollback하지 - 않습니다. -3. DB schema를 확장할 때 version을 올리고 기존 `templates`, `drafts`, `assets`, - `migrations`, `quarantine` store를 그대로 보존합니다. -4. Google 로그인은 동기화와 여러 기기 사용을 위한 선택 기능입니다. 개인 템플릿 - 편집 자체의 선행 조건이 아닙니다. -5. Worker는 인증, 사용자별 object namespace, optimistic concurrency와 공유 수명만 - 담당합니다. 템플릿 편집·검증·압축·미리보기는 프론트에 둡니다. - -stateful 계층이 추가되기 전에는 로그인, 여러 기기 동기화, cloud share, 커뮤니티 -게시를 제공한다고 표시하지 않습니다. +| 템플릿 생성·조회·수정·적용 | IndexedDB `templates` | 정상 동작 | +| 사용자 아이콘 | IndexedDB `assets` | 업로드·편집 가능 | +| 전체 백업·복원 | JSON file | 정상 동작 | +| 손상 레코드 보존 | IndexedDB `quarantine` | 원본 내보내기 가능 | +| 여러 기기 동기화 | outbox → Supabase | 로컬 변경을 대기열에 보존 | +| 갤러리 | Supabase RPC/Storage | 기본 제공 템플릿 fallback | +| 게시·좋아요·닉네임 | Supabase | 재시도 안내, 로컬 데이터 무영향 | + +## IndexedDB schema + +현재 DB 이름은 `linku`, version은 5입니다. 배포된 local-only version 4에서 다음 +store만 additive하게 추가합니다. + +- `outbox`: template/asset별 마지막 put/delete 작업 +- `syncMeta`: remote revision, content hash, 게시 snapshot 상태 +- `settings`: 최초 연결한 account ID + +기존 `templates`, `drafts`, `assets`, `migrations`, `quarantine`는 다시 쓰거나 +삭제하지 않습니다. 이전 localStorage template은 fingerprint 기반 migration으로 +한 번 가져오며 읽을 수 없는 값은 삭제 대신 격리합니다. + +템플릿 저장과 outbox 갱신, 아이콘 저장과 outbox 갱신은 각각 같은 IndexedDB +transaction입니다. 따라서 로컬 성공 뒤 동기화 항목이 사라지는 중간 상태가 없습니다. + +## 동기화 규칙 + +1. 아이콘을 먼저 올립니다. +2. 템플릿은 마지막으로 본 remote revision을 함께 전송합니다. +3. revision이 맞으면 remote revision을 증가시키고 outbox를 지웁니다. +4. 충돌하면 로컬 변경을 새 UUID의 복사본으로 보존하고 remote 최신본을 적용합니다. +5. remote tombstone은 다른 기기의 로컬 항목을 삭제합니다. + +무료 Postgres에 삭제 이력이 끝없이 쌓이지 않도록 계정별 최신 tombstone 100개를 +유지합니다. 그보다 오래 오프라인이었던 기기에서 이미 정리된 항목이 다시 발견되면 +원격본을 덮지 않고 새 UUID의 충돌 복사본으로 복구합니다. + +자동 동기화는 로그인 직후, 온라인 복귀와 로컬 템플릿 변경 때 실행합니다. 같은 +runtime의 중복 실행은 하나의 promise로 직렬화합니다. 수동 `지금 동기화`도 같은 +service를 사용합니다. + +로그아웃은 session만 지우며 로컬 데이터와 outbox를 유지합니다. 한 Chrome profile에 +서로 다른 계정 데이터를 합치지 않도록 account binding도 유지합니다. `LinKU 클라우드 +데이터 삭제`는 remote template, icon, publication과 like를 삭제하지만 로컬 IndexedDB와 +Supabase Auth user 자체는 삭제하지 않습니다. + +## 게시 snapshot + +게시물에는 공개에 필요한 `name`, `height`, `items`만 복사합니다. staging item, +Google profile과 내부 account ID는 포함하지 않습니다. 원본의 공개 내용 hash가 마지막 +게시 hash와 다르면 업데이트 필요 상태가 됩니다. 업데이트 전까지 기존 snapshot을 +계속 보여 주므로 작성 중 변경이 공개 화면에 섞이지 않습니다. + +게시물 복제는 로그인 없이 로컬에 저장할 수 있습니다. 공개 clone counter는 익명 +쓰기 API를 열지 않도록 Google 로그인 상태에서만 best-effort로 집계하며, 집계 실패가 +로컬 복제 결과를 되돌리지 않습니다. + +## 제한과 복구 + +- 계정당 active template 100개, user icon 100개, active publication 25개 +- template JSON 256 KiB 이하 +- icon 하나당 512 KiB 이하의 WebP +- publication 목록은 한 요청에 최대 24개 + +로컬 저장 공간 부족은 동기화 실패와 별도로 안내합니다. 명시적인 전체 JSON 백업은 +계정 동기화 여부와 무관한 복구 경로로 유지합니다. 기존 Spring backend의 데이터는 +자동 이관하지 않으며 KU email 인증, 학과 구독, 공지 crawler와 단일 템플릿 직접 +공유는 폐기합니다. diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index f55d20a0..f5258df4 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -3,15 +3,12 @@ LinKU의 Sentry 연동은 Chrome Extension의 세 런타임을 같은 프로젝트로 묶습니다. - popup: React Error Boundary와 전역 브라우저 오류 -- background: 전역 오류·unhandled rejection, OAuth, silent reauth, 시간표 import, pending tab, badge, service worker lifecycle +- background: 전역 오류·unhandled rejection, PKCE OAuth, 시간표 import, pending tab, badge, service worker lifecycle - content: 전역 오류·unhandled rejection, Everytime 입력 검증·DOM/API 처리·message 응답 실패 -- API/Chrome bridge: 5xx·정상 응답 계약 위반, 토큰 정리, storage/tab/script injection 실패 +- Supabase/Chrome bridge: 예상 밖 응답 계약 위반, session 정리, storage/tab/script injection 실패 - handled application errors: 명시적 `captureErrorLog`/`captureWarnLog` owner와 주요 UI fallback 경로 -GitHub Pages의 share viewer는 이 범위에서 의도적으로 제외합니다. 해당 페이지는 -`connect-src 'none'` CSP로 template fragment가 어떤 원격 collector에도 전송되지 -않게 하며, 잘못된 공유 링크는 페이지 안의 사용자 안내로만 처리합니다. 정적 link -catalog와 grid renderer도 monitoring 의존성이 없는 leaf module만 사용하고, +정적 GitHub Pages site는 이 범위에서 의도적으로 제외합니다. `pnpm run build:gh-pages`가 Rollup module graph를 검사해 `src/monitoring`이나 Sentry SDK가 Pages 산출물에 섞이면 PR과 실제 배포 빌드를 모두 실패시킵니다. @@ -75,8 +72,8 @@ fallback으로 실패를 최종 처리하는 UI·runtime 경계가 원본 오류 실패를 예외가 아니라 `{ success: false, code }`로 돌려주는 정상 결과는 breadcrumb로만 남깁니다. 시간표의 LOGIN_REQUIRED·TAB_UNAVAILABLE·TIMETABLE_NOT_FOUND· -NO_PREVIOUS_SEMESTERS와 LinKU API 4xx·token 만료는 issue가 아닙니다. 실제 exception과 5xx, -2xx 응답 계약 위반만 최종 경계가 한 번 수집합니다. 응답 원문·request body·토큰·쿠키는 +NO_PREVIOUS_SEMESTERS, Supabase validation/RLS·conflict와 session 만료는 issue가 아닙니다. +실제 exception과 2xx 응답 계약 위반만 최종 경계가 한 번 수집합니다. 응답 원문·request body·토큰·쿠키는 수집하지 않고, API 오류는 endpoint path, HTTP method/status, error code, response shape와 직전 breadcrumbs로 재현에 필요한 맥락을 남깁니다. background/content의 `runtime.sendResponse`는 one-shot responder로 감싸 중복 응답과 채널 종료 오류를 별도 diff --git a/package.json b/package.json index 9bd83c46..8d7e3022 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:monitoring": "node --experimental-strip-types --test tests/monitoring/*.test.ts", "test:sentry-bundle": "node scripts/verifySentryBundle.mjs", "test:credentials": "node --experimental-strip-types --test tests/credentials/*.test.ts", - "test:template-share": "node --experimental-strip-types --test tests/templates/*.test.ts", + "test:templates": "node --experimental-strip-types --test tests/templates/*.test.ts", "test:todo": "node --experimental-strip-types --test tests/todo/*.test.ts", "test:banner": "node --experimental-strip-types --test tests/banner/*.test.ts", "test:extension": "pnpm run build:local && playwright test tests/extension", @@ -36,13 +36,13 @@ "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/utilities": "^3.2.2", - "@radix-ui/react-avatar": "^1.2.6", "@radix-ui/react-dialog": "^1.1.23", "@radix-ui/react-dropdown-menu": "^2.1.24", "@radix-ui/react-popover": "^1.1.23", "@radix-ui/react-slot": "^1.3.3", "@radix-ui/react-tabs": "^1.1.21", "@sentry/browser": "^10.70.0", + "@supabase/supabase-js": "^2.112.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -78,6 +78,7 @@ "fake-indexeddb": "^6.2.5", "globals": "^17.9.0", "postcss": "^8.5.25", + "supabase": "^2.116.0", "tailwindcss": "^4.3.3", "typescript": "~6.0.3", "typescript-eslint": "^8.65.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b75b19c..83161b42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,9 +19,6 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.8) - '@radix-ui/react-avatar': - specifier: ^1.2.6 - version: 1.2.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-dialog': specifier: ^1.1.23 version: 1.1.23(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -40,6 +37,9 @@ importers: '@sentry/browser': specifier: ^10.70.0 version: 10.70.0 + '@supabase/supabase-js': + specifier: ^2.112.4 + version: 2.112.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -140,6 +140,9 @@ importers: postcss: specifier: ^8.5.25 version: 8.5.25 + supabase: + specifier: ^2.116.0 + version: 2.116.0 tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -266,6 +269,12 @@ packages: peerDependencies: react: '>=16.8.0' + '@ecies/ciphers@0.2.6': + resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -352,6 +361,18 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} @@ -376,19 +397,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-avatar@1.2.6': - resolution: {integrity: sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-collection@1.1.15': resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} peerDependencies: @@ -925,6 +933,82 @@ packages: resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==} engines: {node: '>= 18'} + '@supabase/auth-js@2.112.4': + resolution: {integrity: sha512-z8DesgwLzKM5PiT0yNmJU8VJyh1zAhYi+20Z7drdJQLXg/wWW4yGt/un+He5ERYUo94Vz66t5aeyr1DIDemI5A==} + engines: {node: '>=22.0.0'} + + '@supabase/cli-darwin-arm64@2.116.0': + resolution: {integrity: sha512-Mvfxf5q7oQ1KR59ndFFyGkh12IfwKH5ZOv7OWtHsFkBuwHtHiJgY6Zwd3w09tnat4spkpDTFavclBlLsOQnh2A==} + cpu: [arm64] + os: [darwin] + + '@supabase/cli-darwin-x64@2.116.0': + resolution: {integrity: sha512-dxKmIPcVunC8sPTuU+eVWj2SOB5tLoRTE5FX6J/KMZhGH03khTn6ptHvaanZp0YwaACbm//uoffUlJKZrAgt0w==} + cpu: [x64] + os: [darwin] + + '@supabase/cli-linux-arm64-musl@2.116.0': + resolution: {integrity: sha512-6lYrbKFJT5NKbEKGBJTArEc1F3oMfWxnQeq8+RZ4wSLjCq4uwluh6+fzKCsLVxZgPOg4r+RZRqDdb+/cLi0yyg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@supabase/cli-linux-arm64@2.116.0': + resolution: {integrity: sha512-ZmV96NQqcgx1MH4jWdfyqqjLghy57mRI5bysy6lM7MezsirQh+eXaOdWI0xCy7r7FA09k2fKLGh+r7r0X3mxBg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@supabase/cli-linux-x64-musl@2.116.0': + resolution: {integrity: sha512-EtPJPHUvLHvXHkvZHAEr+i6w/bDVm5BOPD+09uXgUffsUbNAzfZ8r7Fb94+SfWI+dQwivw4WmijsX7tlx61Zcg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@supabase/cli-linux-x64@2.116.0': + resolution: {integrity: sha512-o0PvHKyQSKEuC3jJqeV2qorgyMIFGDWQ1Bj+OXf0p80ddgktnJFlDElCU+VDKZkuwLC6vO/LMoBql34zFHzXhw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@supabase/cli-windows-arm64@2.116.0': + resolution: {integrity: sha512-IiglNMXXssDiZbeSRvixYH7eYDDvhiEa2CrOSj419jO5vLrMKvzi1ATxe8E4i7MpKuI9S5U/3tA3rFlIMHtwrg==} + cpu: [arm64] + os: [win32] + + '@supabase/cli-windows-x64@2.116.0': + resolution: {integrity: sha512-pz4zNDs3KCEx0l9JS9Xaiuzd5WXrISajVlBSxC5/2Jyo2+g+N/ftQJDYTHQ6Jir5fNelIqSHIXZelmGds14upw==} + cpu: [x64] + os: [win32] + + '@supabase/functions-js@2.112.4': + resolution: {integrity: sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==} + engines: {node: '>=22.0.0'} + + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} + + '@supabase/postgrest-js@2.112.4': + resolution: {integrity: sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==} + engines: {node: '>=22.0.0'} + + '@supabase/realtime-js@2.112.4': + resolution: {integrity: sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==} + engines: {node: '>=22.0.0'} + + '@supabase/storage-js@2.112.4': + resolution: {integrity: sha512-lQ0JemuTlMIXVKgSci1qez8yPnM5hyDngeAfEBjZS2Om4D+Cus0EE5BE6glFobrxdyii1OF4UzWfF0zcQgDq5A==} + engines: {node: '>=22.0.0'} + + '@supabase/supabase-js@2.112.4': + resolution: {integrity: sha512-UiCX1udlFY1fQQrO7Z3GU7obQsju0w5Vk9mOOwalfo/+Gy+tahWVenSSuu5E/GTy/q//HxvGv2IrCdW66/61kw==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -1436,6 +1520,10 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + eciesjs@0.5.0: + resolution: {integrity: sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==} + engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} + electron-to-chromium@1.5.399: resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} @@ -1626,6 +1714,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + idb@8.0.3: resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==} @@ -1667,6 +1759,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2129,6 +2224,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + supabase@2.116.0: + resolution: {integrity: sha512-cMUHkpjBacq4oLGWnMM2HC2drmUlAlfN/PQb31RARoIdYJ8sqA0xONvqBR6yd5v7w8dXuCPwvfd4N1NTHjBKEw==} + hasBin: true + svg-parser@2.0.4: resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} @@ -2444,6 +2543,10 @@ snapshots: react: 19.2.8 tslib: 2.8.1 + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + '@eslint-community/eslint-utils@4.9.1(eslint@10.8.0(jiti@2.7.0))': dependencies: eslint: 10.8.0(jiti@2.7.0) @@ -2525,6 +2628,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + '@oxc-project/types@0.142.0': {} '@playwright/test@1.62.1': @@ -2542,20 +2653,6 @@ snapshots: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-avatar@1.2.6(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) @@ -3033,6 +3130,62 @@ snapshots: - supports-color - webpack + '@supabase/auth-js@2.112.4': + dependencies: + tslib: 2.8.1 + + '@supabase/cli-darwin-arm64@2.116.0': + optional: true + + '@supabase/cli-darwin-x64@2.116.0': + optional: true + + '@supabase/cli-linux-arm64-musl@2.116.0': + optional: true + + '@supabase/cli-linux-arm64@2.116.0': + optional: true + + '@supabase/cli-linux-x64-musl@2.116.0': + optional: true + + '@supabase/cli-linux-x64@2.116.0': + optional: true + + '@supabase/cli-windows-arm64@2.116.0': + optional: true + + '@supabase/cli-windows-x64@2.116.0': + optional: true + + '@supabase/functions-js@2.112.4': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.5': {} + + '@supabase/postgrest-js@2.112.4': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.112.4': + dependencies: + '@supabase/phoenix': 0.4.5 + tslib: 2.8.1 + + '@supabase/storage-js@2.112.4': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.112.4': + dependencies: + '@supabase/auth-js': 2.112.4 + '@supabase/functions-js': 2.112.4 + '@supabase/postgrest-js': 2.112.4 + '@supabase/realtime-js': 2.112.4 + '@supabase/storage-js': 2.112.4 + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -3513,6 +3666,13 @@ snapshots: dotenv@17.4.2: {} + eciesjs@0.5.0: + dependencies: + '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + electron-to-chromium@1.5.399: {} embla-carousel-autoplay@8.6.0(embla-carousel@8.6.0): @@ -3705,6 +3865,8 @@ snapshots: transitivePeerDependencies: - supports-color + iceberg-js@0.8.1: {} + idb@8.0.3: {} ignore@5.3.2: {} @@ -3732,6 +3894,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.10: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -4104,6 +4268,20 @@ snapshots: dependencies: ansi-regex: 5.0.1 + supabase@2.116.0: + dependencies: + eciesjs: 0.5.0 + jose: 6.2.10 + optionalDependencies: + '@supabase/cli-darwin-arm64': 2.116.0 + '@supabase/cli-darwin-x64': 2.116.0 + '@supabase/cli-linux-arm64': 2.116.0 + '@supabase/cli-linux-arm64-musl': 2.116.0 + '@supabase/cli-linux-x64': 2.116.0 + '@supabase/cli-linux-x64-musl': 2.116.0 + '@supabase/cli-windows-arm64': 2.116.0 + '@supabase/cli-windows-x64': 2.116.0 + svg-parser@2.0.4: {} tailwind-merge@3.6.0: {} diff --git a/public/manifest.json b/public/manifest.json index 4bb4e608..db005572 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -39,14 +39,8 @@ "", "*://ecampus.konkuk.ac.kr/*", "*://library.konkuk.ac.kr/*", - "https://www.google-analytics.com/*", - "https://ku-linku.store/*" + "https://www.google-analytics.com/*" ], - "externally_connectable": { - "matches": [ - "https://turtle-hwan.github.io/LinKU/*" - ] - }, "commands": { "_execute_action": { "suggested_key": { @@ -56,4 +50,4 @@ "description": "Open LinKU extension" } } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index d2d55cc2..022fcd88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,18 +7,17 @@ import { useEffect } from "react"; import { Outlet } from "react-router"; import { ErrorBoundary } from "react-error-boundary"; import { Toaster } from "./components/ui/sonner"; -import { toast } from "sonner"; import { recordBreadcrumb, reportError, } from "./monitoring"; import { sendExtensionOpen, sendPageView, sendError } from "./utils/analytics"; -import { debugLog, captureErrorLog } from "@/utils/logger"; -import { consumePendingTemplateImports } from "@/utils/pendingTemplateImports"; -import { importSharedTemplate } from "@/utils/templateStorage"; +import { debugLog } from "@/utils/logger"; import "./App.css"; +import { useAccountSync } from "@/hooks/useAccountSync"; function App() { + useAccountSync(); // GA4: popup mount 시 first_open / session_start / extension_open 자동 전송 useEffect(() => { debugLog( @@ -33,28 +32,6 @@ function App() { sendPageView("LinKU Extension - Popup"); }, []); - useEffect(() => { - void consumePendingTemplateImports(async (payload) => { - await importSharedTemplate(payload); - }) - .then(({ importedCount, failedCount }) => { - if (importedCount > 0) { - window.dispatchEvent(new Event("linku:templates-changed")); - toast.success("템플릿 가져오기 완료", { - description: `${importedCount}개를 이 기기에 저장했습니다.`, - }); - } - if (failedCount > 0) { - toast.error("일부 템플릿을 가져오지 못했습니다", { - description: `실패한 ${failedCount}개는 다음 실행 때 다시 시도합니다.`, - }); - } - }) - .catch((error: unknown) => { - captureErrorLog("Failed to process pending template imports", error); - }); - }, []); - return ( { diff --git a/src/apis/alerts.ts b/src/apis/alerts.ts index 51c2fdc8..1b3ae3c8 100644 --- a/src/apis/alerts.ts +++ b/src/apis/alerts.ts @@ -1,15 +1,12 @@ /** * Alerts API - * Notification and subscription management + * Direct public notice cache */ -import { get, post, del, ENDPOINTS } from './client'; import type { ApiResponse, GeneralAlert, AlertFilterParams, - Department, - Subscription, } from '../types/api'; import { getCachedPublicAlerts, @@ -89,113 +86,3 @@ export async function getAlerts( return failedAlertsResponse(); } } - -/** - * Backend response type for my alerts API - */ -interface MyAlertsResponse { - alertResponseList: Array<{ - alertId: number; - departmentName: string; - url: string; - title: string; - postTime: string; - content: string; - }>; -} - -/** - * Get my alerts - * Fetch alerts from subscribed departments - */ -export async function getMyAlerts(): Promise> { - const response = await get(ENDPOINTS.ALERTS.MY); - - if (response.success && response.data?.alertResponseList) { - // Transform to GeneralAlert format - const alerts: GeneralAlert[] = response.data.alertResponseList.map(item => ({ - alertId: item.alertId, - title: item.title, - content: item.content, - category: item.departmentName as GeneralAlert['category'], - url: item.url, - publishedAt: item.postTime, - })); - return { ...response, data: alerts }; - } - - return { ...response, data: [] }; -} - -/** - * Backend response type for subscription API - */ -interface DepartmentConfigResponse { - departmentConfigList: Array<{ - departmentConfigId: number; - departmentConfigName: string; - }>; -} - -/** - * Get all available departments for subscription - * Transforms backend response to frontend Department format - */ -export async function getSubscriptions(): Promise> { - const response = await get(ENDPOINTS.ALERTS.SUBSCRIPTION); - - if (response.success && response.data?.departmentConfigList) { - // Transform field names to match frontend Department type - // Use type assertion since API may return categories not in DepartmentCategory - const departments = response.data.departmentConfigList.map(item => ({ - id: item.departmentConfigId, - name: item.departmentConfigName, - })) as Department[]; - return { ...response, data: departments }; - } - - return { ...response, data: [] }; -} - -/** - * Get my subscribed departments - * Uses same response structure as getSubscriptions (departmentConfigList) - */ -export async function getMySubscriptions(): Promise> { - const response = await get(ENDPOINTS.ALERTS.MY_SUBSCRIPTION); - - if (response.success && response.data?.departmentConfigList) { - // Transform to Subscription format (using departmentConfigId as subscriptionId) - const subscriptions: Subscription[] = response.data.departmentConfigList.map(item => ({ - subscriptionId: item.departmentConfigId, - department: { - id: item.departmentConfigId, - name: item.departmentConfigName, - } as Department, - createdAt: '', - })); - return { ...response, data: subscriptions }; - } - - return { ...response, data: [] }; -} - -/** - * Subscribe to a department - * Start receiving alerts from the department - */ -export async function subscribeDepartment( - departmentId: number -): Promise> { - return post(ENDPOINTS.ALERTS.SUBSCRIBE(departmentId)); -} - -/** - * Unsubscribe from a department - * Stop receiving alerts from the department - */ -export async function unsubscribeDepartment( - departmentId: number -): Promise> { - return del<{ message: string }>(ENDPOINTS.ALERTS.UNSUBSCRIBE(departmentId)); -} diff --git a/src/apis/auth.ts b/src/apis/auth.ts deleted file mode 100644 index f83a68df..00000000 --- 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 deleted file mode 100644 index c2124882..00000000 --- a/src/apis/client.ts +++ /dev/null @@ -1,595 +0,0 @@ -/** - * HTTP Client for LinKU API - * Consolidated client with type-safe HTTP methods and auth interceptors - */ - -import type { ApiResponse, RequestConfig } from "../types/api"; -import { BackgroundMessageType } from "../background/types"; -import type { SilentReauthResponse } from "../background/types"; -import { getChromeApi, getStorage, removeStorage } from "../utils/chrome"; -import { - debugLog, - getErrorLogDetails, - warnLog, -} from "@/utils/logger"; -import { - createErrorReporter, - recordBreadcrumb, - reportMessage, -} from "@/monitoring"; -import { - classifyNetworkFailure, - isExpectedNetworkFailure, -} from "@/utils/networkFailure"; - -/** - * Token expired error code from backend - */ -const TOKEN_EXPIRED_CODE = 5004; - -function getSafeEndpoint(url: string): string { - try { - const baseUrl = - typeof window !== "undefined" - ? window.location.origin - : "chrome-extension://linku.invalid"; - return new URL(url, baseUrl).pathname; - } catch { - return url.split("?")[0] || "[unknown]"; - } -} - -const captureApiException = createErrorReporter({ - category: "api.error", - mechanism: "fetch", -}); - -function reportApiException( - error: unknown, - feature: string, - extras?: Record, -): void { - captureApiException(error, { - feature, - breadcrumbMessage: `${feature} captured`, - extras, - }); -} - -function getApiErrorCode(data: unknown): string | undefined { - if (!data || typeof data !== "object" || !("code" in data)) { - return undefined; - } - - const code = (data as Record).code; - return typeof code === "string" || typeof code === "number" - ? String(code).slice(0, 64) - : undefined; -} - -function getResponseShape(data: unknown): Record { - if (Array.isArray(data)) { - return { response_type: "array", response_length: data.length }; - } - - if (data && typeof data === "object") { - const keys = Object.keys(data); - return { - response_type: "object", - response_key_count: keys.length, - response_keys: keys.slice(0, 20), - }; - } - - return { response_type: typeof data }; -} - -function recordApiHttpFailure( - method: string, - endpoint: string, - response: Response, - data: unknown, -): void { - const status = response.status; - const level = status >= 500 ? "error" : "warning"; - const extras = { - endpoint, - method: method.toUpperCase(), - status, - status_text: response.statusText, - error_code: getApiErrorCode(data), - ...getResponseShape(data), - }; - - recordBreadcrumb( - "api.response", - "non-success HTTP response", - extras, - level, - ); - - // 4xx responses and backend user-facing codes are request outcomes, not - // product defects. Only an unexpected server-side terminal response owns a - // Sentry issue here. - if (status < 500) { - return; - } - - reportMessage(`LinKU API HTTP ${status}`, { - feature: "api_http_error", - category: "api.response", - breadcrumbMessage: "server-side HTTP failure", - level, - mechanism: "fetch.response", - tags: { - http_status: String(status), - http_method: method.toUpperCase(), - }, - extras, - }); -} - -async function readHttpFailureBody( - response: Response, - endpoint: string, -): Promise { - const contentType = response.headers.get("content-type"); - - try { - const bodyText = await response.text(); - if (!contentType?.includes("application/json") || !bodyText.trim()) { - return bodyText; - } - - try { - return JSON.parse(bodyText) as unknown; - } catch (error) { - // The HTTP status is the failure owner. A proxy-generated HTML error - // page that incorrectly advertises JSON must not create a parse issue in - // addition to the HTTP outcome. - recordBreadcrumb( - "api.response", - "non-success response body was not valid JSON", - { - endpoint, - status: response.status, - content_type: contentType, - }, - "warning", - ); - warnLog( - "[API Client] Ignoring malformed non-success response body", - getErrorLogDetails(error), - ); - return bodyText; - } - } catch (error) { - recordBreadcrumb( - "api.response", - "non-success response body was unavailable", - { - endpoint, - status: response.status, - content_type: contentType, - }, - "warning", - ); - warnLog( - "[API Client] Failed to read non-success response body", - getErrorLogDetails(error), - ); - return undefined; - } -} - -function isTokenExpiredResponse(data: unknown): boolean { - if (!data || typeof data !== "object" || !("code" in data)) { - return false; - } - - const code = (data as Record).code; - return code === TOKEN_EXPIRED_CODE || code === String(TOKEN_EXPIRED_CODE); -} - -/** - * Reauth state to prevent multiple simultaneous OAuth popups - */ -let isReauthenticating = false; -let reauthPromise: Promise | null = null; - -/** - * API Base URL - */ -export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; - -/** - * API Endpoints - */ -export const ENDPOINTS = { - // Auth - AUTH: { - GOOGLE_OAUTH: "/oauth2/authorization/google", - SEND_CODE: "/auth/send-code", - VERIFY_CODE: "/auth/verify-code", - }, - - // Alerts - ALERTS: { - MY: "/alerts/my", - SUBSCRIPTION: "/alerts/subscription", - MY_SUBSCRIPTION: "/alerts/subscription/my", - SUBSCRIBE: (departmentId: number) => `/alerts/subscription/${departmentId}`, - UNSUBSCRIBE: (departmentId: number) => - `/alerts/subscription/${departmentId}`, - }, -} as const; - -/** - * Token Management - * Using chrome.storage.local for persistent token storage - */ -async function getAccessToken(): Promise { - const token = await getStorage("accessToken"); - return typeof token === "string" ? token : null; -} - -async function clearAccessToken(): Promise { - await removeStorage([ - "accessToken", - "refreshToken", - "guestToken", - ]); -} - -/** - * Handle token expiration by triggering silent re-authentication - * Sends SILENT_REAUTH message to background script to re-trigger Google OAuth - * Uses flags to prevent multiple simultaneous OAuth popups - * @returns Promise - true if reauth succeeded, false otherwise - */ -async function handleTokenExpired(): Promise { - // If already reauthenticating, wait for the existing promise - if (isReauthenticating && reauthPromise) { - debugLog("[API Client] Reauth already in progress, waiting..."); - return reauthPromise; - } - - debugLog("[API Client] Token expired (5004), attempting silent reauth..."); - - const chromeApi = getChromeApi(); - if (!chromeApi?.runtime?.sendMessage) { - return false; - } - - isReauthenticating = true; - reauthPromise = (async () => { - try { - const response = await chromeApi.runtime.sendMessage< - { type: BackgroundMessageType.SILENT_REAUTH }, - SilentReauthResponse - >({ - type: BackgroundMessageType.SILENT_REAUTH, - }); - - if (response?.success) { - debugLog("[API Client] Silent reauth succeeded"); - return true; - } else { - recordBreadcrumb("api.auth", "silent reauth was not completed", { - error: response?.error, - }); - warnLog("[API Client] Silent reauth failed", { - error: response?.error, - }); - return false; - } - } catch (error) { - reportApiException(error, "silent_reauth_request"); - warnLog("[API Client] Silent reauth error", getErrorLogDetails(error)); - return false; - } finally { - isReauthenticating = false; - reauthPromise = null; - } - })(); - - return reauthPromise; -} - -/** - * Request Interceptors - */ -async function applyRequestInterceptors( - options: RequestInit, -): Promise { - const headers = new Headers(options.headers); - const token = await getAccessToken(); - - if (token) { - headers.set("Authorization", `Bearer ${token}`); - } - - return { - ...options, - headers, - }; -} - -/** - * Response Interceptors - */ -function applyResponseInterceptors( - response: ApiResponse, -): ApiResponse { - if (response.status === 401) { - void clearAccessToken().catch((error: unknown) => { - reportApiException(error, "clear_expired_access_token"); - }); - window.dispatchEvent(new CustomEvent("auth:unauthorized")); - } - return response; -} - -/** - * Build URL with query parameters - */ -function buildUrl(url: string, params?: unknown): string { - if (!params) return url; - - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } - }); - - const separator = url.includes("?") ? "&" : "?"; - const queryString = searchParams.toString(); - return queryString ? `${url}${separator}${queryString}` : url; -} - -/** - * Core request function - * @param isRetry - Internal flag to prevent infinite retry loops on 5004 error - */ -async function request( - url: string, - method: string, - body?: unknown, - config?: RequestConfig, - isRetry: boolean = false, -): Promise> { - let safeEndpoint = getSafeEndpoint(url); - - try { - const { headers = {}, params, ...restConfig } = config || {}; - - // Build full URL - const urlWithParams = buildUrl(url, params); - const fullUrl = - url.startsWith("http://") || url.startsWith("https://") - ? urlWithParams - : `${API_BASE_URL}${urlWithParams}`; - safeEndpoint = getSafeEndpoint(fullUrl); - - // Build request options - let requestOptions: RequestInit = { - method, - headers: { - "Content-Type": "application/json", - ...headers, - }, - credentials: "include", - ...restConfig, - }; - - // Add body - if (body !== undefined) { - if (body instanceof FormData) { - delete (requestOptions.headers as Record)[ - "Content-Type" - ]; - requestOptions.body = body; - } else if ( - headers["Content-Type"] === "application/x-www-form-urlencoded" - ) { - requestOptions.body = body as string; - } else { - requestOptions.body = JSON.stringify(body); - } - } - - // Apply interceptors - requestOptions = await applyRequestInterceptors(requestOptions); - - recordBreadcrumb("api.request", "request started", { - endpoint: safeEndpoint, - method: method.toUpperCase(), - retry: isRetry, - has_body: body !== undefined, - }); - - // Fetch - const response = await fetch(fullUrl, requestOptions); - recordBreadcrumb( - "api.response", - "response received", - { - endpoint: safeEndpoint, - method: method.toUpperCase(), - status: response.status, - ok: response.ok, - }, - response.ok - ? "info" - : response.status >= 500 - ? "error" - : "warning", - ); - - // Route non-success responses before parsing success payloads. Error pages - // from proxies and upstream servers frequently contain HTML even when the - // content-type claims JSON; the HTTP status remains the failure owner. - const contentType = response.headers.get("content-type"); - let data: T; - - if (!response.ok) { - data = (await readHttpFailureBody(response, safeEndpoint)) as T; - } else { - try { - if (contentType?.includes("application/json")) { - data = await response.json(); - } else { - data = (await response.text()) as T; - } - } catch (parseError) { - reportApiException(parseError, "api_response_parse", { - endpoint: safeEndpoint, - status: response.status, - content_type: contentType, - }); - warnLog("[API Client] Response parsing error", { - ...getErrorLogDetails(parseError), - status: response.status, - endpoint: safeEndpoint, - }); - return { - success: false, - error: { - code: "PARSE_ERROR", - message: "서버 응답을 읽지 못했습니다. 잠시 후 다시 시도해주세요.", - }, - status: response.status, - }; - } - } - - // Check for token expired error (5004) and attempt silent reauth - if (isTokenExpiredResponse(data)) { - const authExtras = { - endpoint: safeEndpoint, - method: method.toUpperCase(), - status: response.status, - error_code: String(TOKEN_EXPIRED_CODE), - retry: isRetry, - }; - recordBreadcrumb( - "api.auth", - "expired token response handled", - authExtras, - "warning", - ); - debugLog( - "[API Client] Detected 5004 token expired error, attempting reauth...", - ); - - const reauthSuccess = !isRetry && (await handleTokenExpired()); - - if (reauthSuccess) { - // Retry the original request with new token - debugLog("[API Client] Retrying request after successful reauth"); - return request(url, method, body, config, true); - } else { - // Reauth failed, clear tokens and notify - warnLog("[API Client] Reauth failed, clearing tokens"); - await clearAccessToken(); - window.dispatchEvent(new CustomEvent("auth:unauthorized")); - - return { - success: false, - error: { - code: String(TOKEN_EXPIRED_CODE), - message: "세션이 만료되었습니다. 다시 로그인해주세요.", - }, - status: 401, - }; - } - } - - // Handle error responses FIRST (preserve original error data before result extraction) - if (!response.ok) { - const errorData = - data && typeof data === "object" - ? (data as Record) - : undefined; - recordApiHttpFailure(method, safeEndpoint, response, data); - return applyResponseInterceptors({ - success: false, - error: { - code: String(errorData?.code || response.status), - message: - (errorData?.message as string) || - `HTTP Error: ${response.status} ${response.statusText}`, - }, - status: response.status, - data, - }); - } - - // For SUCCESS responses only: extract 'result' field if present - if (data && typeof data === "object" && "result" in data) { - const backendResponse = data as Record; - if ( - backendResponse.result !== undefined && - backendResponse.result !== null - ) { - data = backendResponse.result as T; - } - } - - return applyResponseInterceptors({ - success: true, - data, - status: response.status, - }); - } catch (error) { - const networkFailureKind = classifyNetworkFailure(error); - const failureContext = { - endpoint: safeEndpoint, - method, - network_failure_kind: networkFailureKind, - }; - recordBreadcrumb( - "api.network", - "request transport failed", - failureContext, - "warning", - ); - if (!isExpectedNetworkFailure(error)) { - reportApiException(error, "api_network_error", failureContext); - } - warnLog("[API Client] Request error", getErrorLogDetails(error)); - return { - success: false, - error: { - code: "NETWORK_ERROR", - message: "네트워크 연결을 확인한 뒤 다시 시도해주세요.", - }, - }; - } -} - -/** - * HTTP Methods - */ -export async function get( - url: string, - config?: RequestConfig, -): Promise> { - return request(url, "GET", undefined, config); -} - -export async function post( - url: string, - data?: unknown, - config?: RequestConfig, -): Promise> { - return request(url, "POST", data, config); -} - -export async function del( - url: string, - config?: RequestConfig, -): Promise> { - return request(url, "DELETE", undefined, config); -} diff --git a/src/apis/index.ts b/src/apis/index.ts index 5830fde9..08827ce2 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -3,11 +3,7 @@ * Centralized export for all API endpoints */ -// HTTP Client & Configuration -export * from "./client"; - // Domain APIs -export * from "./auth"; export * from "./alerts"; // External Integrations diff --git a/src/apis/supabase/account.ts b/src/apis/supabase/account.ts new file mode 100644 index 00000000..60f40785 --- /dev/null +++ b/src/apis/supabase/account.ts @@ -0,0 +1,59 @@ +import { getSupabaseClient } from "@/apis/supabase/client"; +import { + toSupabaseAuthError, + toSupabaseUserError, +} from "@/apis/supabase/errors"; +import type { AccountProfile } from "@/types/account"; +import { UserFacingError } from "@/errors/userFacingError"; + +function isGoogleUser(appMetadata: Record): boolean { + if (appMetadata.provider === "google") return true; + return Array.isArray(appMetadata.providers) && + appMetadata.providers.includes("google"); +} + +export async function getGoogleAccountId(): Promise { + const { data, error } = await getSupabaseClient().auth.getSession(); + if (error) throw toSupabaseAuthError(error, "계정 정보를 불러오지 못했습니다."); + const user = data.session?.user; + return user && isGoogleUser(user.app_metadata) ? user.id : null; +} + +export async function getAccountProfile(): Promise { + const client = getSupabaseClient(); + const userId = await getGoogleAccountId(); + if (!userId) return null; + + const { data, error } = await client + .from("profiles") + .select("nickname") + .eq("user_id", userId) + .single(); + if (error) throw toSupabaseUserError(error, "계정 정보를 불러오지 못했습니다."); + return { userId, nickname: data.nickname }; +} + +export async function updateAccountNickname( + nickname: string, +): Promise { + const client = getSupabaseClient(); + const { data: sessionData, error: sessionError } = await client.auth.getSession(); + if (sessionError) { + throw toSupabaseAuthError(sessionError, "계정 정보를 불러오지 못했습니다."); + } + const userId = sessionData.session?.user.id; + if (!userId) { + throw new UserFacingError("Google 로그인이 필요합니다.", "LOGIN_REQUIRED"); + } + + const { data, error } = await client.rpc("update_nickname", { + p_nickname: nickname, + }); + if (error) throw toSupabaseUserError(error, "닉네임을 저장하지 못했습니다."); + return { userId, nickname: data.nickname }; +} + +export async function signOutAccount(): Promise { + const { error } = await getSupabaseClient().auth.signOut({ scope: "local" }); + if (error) throw toSupabaseAuthError(error, "로그아웃을 완료하지 못했습니다."); +} diff --git a/src/apis/supabase/client.ts b/src/apis/supabase/client.ts new file mode 100644 index 00000000..1b056ff1 --- /dev/null +++ b/src/apis/supabase/client.ts @@ -0,0 +1,83 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import type { Database } from "@/types/supabase"; + +const AUTH_STORAGE_KEY = "linku.supabase.auth.v1"; +const LEGACY_AUTH_KEYS = [ + "accessToken", + "accessTokenExpiresAt", + "refreshToken", + "guestToken", + "isGuest", + "kuMail", + "userProfile", + "syncAccountId", +] as const; + +export class SupabaseConfigurationError extends Error { + constructor() { + super("Supabase 연결 정보가 설정되지 않았습니다."); + this.name = "SupabaseConfigurationError"; + } +} + +const extensionStorage = { + async getItem(key: string): Promise { + if (globalThis.chrome?.storage?.local) { + const stored = await chrome.storage.local.get(key); + return typeof stored[key] === "string" ? stored[key] : null; + } + return globalThis.localStorage?.getItem(key) ?? null; + }, + async setItem(key: string, value: string): Promise { + if (globalThis.chrome?.storage?.local) { + await chrome.storage.local.set({ [key]: value }); + return; + } + globalThis.localStorage?.setItem(key, value); + }, + async removeItem(key: string): Promise { + if (globalThis.chrome?.storage?.local) { + await chrome.storage.local.remove(key); + return; + } + globalThis.localStorage?.removeItem(key); + }, +}; + +let client: SupabaseClient | undefined; + +export function getSupabaseClient(): SupabaseClient { + if (client) return client; + + const url = import.meta.env.VITE_SUPABASE_URL?.trim(); + const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY?.trim(); + if (!url || !publishableKey) throw new SupabaseConfigurationError(); + + client = createClient(url, publishableKey, { + auth: { + autoRefreshToken: false, + detectSessionInUrl: false, + flowType: "pkce", + persistSession: true, + storage: extensionStorage, + storageKey: AUTH_STORAGE_KEY, + }, + }); + return client; +} + +export function isSupabaseConfigured(): boolean { + return Boolean( + import.meta.env.VITE_SUPABASE_URL?.trim() && + import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY?.trim(), + ); +} + +export async function clearLegacyAuthStorage(): Promise { + if (!globalThis.chrome?.storage?.local) return; + await chrome.storage.local.remove([...LEGACY_AUTH_KEYS]); +} + +export async function clearStoredSupabaseSession(): Promise { + await extensionStorage.removeItem(AUTH_STORAGE_KEY); +} diff --git a/src/apis/supabase/community.ts b/src/apis/supabase/community.ts new file mode 100644 index 00000000..4d5f51cb --- /dev/null +++ b/src/apis/supabase/community.ts @@ -0,0 +1,467 @@ +import { getGoogleAccountId } from "@/apis/supabase/account"; +import { getSupabaseClient } from "@/apis/supabase/client"; +import { + toSupabaseAuthError, + toSupabaseStorageError, + toSupabaseUserError, +} from "@/apis/supabase/errors"; +import { UserFacingError } from "@/errors/userFacingError"; +import { + clearCloudSyncState, + getActiveSyncAccountId, + getSyncMetadata, + replacePublicationMetadata, + setSyncMetadata, + syncMetadataKey, +} from "@/storage/account/syncRepository"; +import { + getAssetById, + saveImportedCloudAsset, +} from "@/storage/templates/assetRepository"; +import { + findTemplateBySyncId, + importTemplateCopy, +} from "@/storage/templates/repository"; +import { + createCloudTemplateDocument, + hashPublishedTemplate, + parsePublishedTemplateSnapshot, + publishedSnapshotToTemplate, +} from "@/sync/templateDocument"; +import type { + CloudTemplateDocumentV1, + PublicationSort, + PublishedTemplateSnapshotV1, + TemplatePublication, +} from "@/types/account"; +import type { Database } from "@/types/supabase"; +import type { StoredTemplate } from "@/storage/indexedDb/linkuDatabase"; +import { syncAccount } from "@/utils/accountSync"; +import { recordBreadcrumb } from "@/monitoring"; + +type PublicationRow = + Database["public"]["Tables"]["template_publications"]["Row"]; +type BrowseRow = + Database["public"]["Functions"]["browse_publications"]["Returns"][number]; + +const PUBLIC_BUCKET = "published-template-assets"; + +function mapOwnPublication(row: PublicationRow): TemplatePublication { + return { + templateId: row.template_id, + snapshot: parsePublishedTemplateSnapshot(row.snapshot), + revision: row.revision, + sourceContentHash: row.source_content_hash, + authorNickname: row.author_nickname, + likeCount: row.like_count, + cloneCount: row.clone_count, + publishedAt: row.published_at, + updatedAt: row.updated_at, + unpublishedAt: row.unpublished_at, + }; +} + +function mapBrowsePublication(row: BrowseRow): TemplatePublication { + return { + templateId: row.template_id, + snapshot: parsePublishedTemplateSnapshot(row.snapshot), + revision: row.revision, + authorNickname: row.author_nickname, + likeCount: row.like_count, + cloneCount: row.clone_count, + publishedAt: row.published_at, + updatedAt: row.updated_at, + isLiked: row.is_liked, + }; +} + +export async function browsePublications(options: { + query?: string; + sort?: PublicationSort; + offset?: number; + limit?: number; +} = {}): Promise { + const { data, error } = await getSupabaseClient().rpc("browse_publications", { + p_query: options.query ?? "", + p_sort: options.sort ?? "latest", + p_offset: options.offset ?? 0, + p_limit: options.limit ?? 12, + }); + if (error) throw toSupabaseUserError(error, "게시된 템플릿을 불러오지 못했습니다."); + return data.map(mapBrowsePublication); +} + +export async function listOwnPublications(): Promise { + const { data, error } = await getSupabaseClient() + .from("template_publications") + .select( + "template_id, owner_id, snapshot, source_content_hash, revision, author_nickname, like_count, clone_count, published_at, updated_at, unpublished_at", + ); + if (error) throw toSupabaseUserError(error, "게시 상태를 불러오지 못했습니다."); + return data.map(mapOwnPublication); +} + +function assetHashes(document: CloudTemplateDocumentV1 | PublishedTemplateSnapshotV1) { + return new Set( + document.items.flatMap((item) => + item.icon.kind === "asset" ? [item.icon.hash] : [], + ), + ); +} + +function publicAssetPath(templateId: string, hash: string): string { + return `${templateId}/${hash}.webp`; +} + +export function getPublishedAssetUrl(templateId: string, hash: string): string { + return getSupabaseClient().storage + .from(PUBLIC_BUCKET) + .getPublicUrl(publicAssetPath(templateId, hash)).data.publicUrl; +} + +export function createPublicationPreview(publication: TemplatePublication) { + return publishedSnapshotToTemplate( + publication.snapshot, + async (hash, name) => ({ + numericId: 0, + name, + dataUrl: getPublishedAssetUrl(publication.templateId, hash), + }), + ); +} + +async function uploadPublishedAssets( + templateId: string, + document: CloudTemplateDocumentV1, + existingHashes: Set, +): Promise> { + const hashes = assetHashes(document); + for (const hash of hashes) { + if (existingHashes.has(hash)) continue; + const asset = await getAssetById(hash); + if (!asset) { + throw new UserFacingError("게시에 필요한 아이콘을 이 기기에서 찾을 수 없습니다."); + } + const { error } = await getSupabaseClient().storage + .from(PUBLIC_BUCKET) + .upload(publicAssetPath(templateId, hash), asset.blob, { + cacheControl: "31536000", + contentType: "image/webp", + upsert: true, + }); + if (error) { + throw toSupabaseStorageError(error, "게시 아이콘을 올리지 못했습니다."); + } + } + return hashes; +} + +async function removeUnreferencedPublicAssets( + templateId: string, + activeHashes: Set, +): Promise { + const bucket = getSupabaseClient().storage.from(PUBLIC_BUCKET); + while (true) { + const { data, error } = await bucket.list(templateId, { limit: 100 }); + if (error) { + throw toSupabaseStorageError(error, "게시 아이콘을 정리하지 못했습니다."); + } + const stale = data + .map((file) => file.name) + .filter((name) => name.endsWith(".webp")) + .filter((name) => !activeHashes.has(name.slice(0, -".webp".length))) + .map((name) => `${templateId}/${name}`); + if (stale.length === 0) return; + const { error: removeError } = await bucket.remove(stale); + if (removeError) { + throw toSupabaseStorageError(removeError, "게시 아이콘을 정리하지 못했습니다."); + } + } +} + +async function savePublicationMetadata( + publication: TemplatePublication, + isPublished: boolean, +): Promise { + const accountId = await getActiveSyncAccountId(); + if (!accountId) return; + const key = syncMetadataKey(accountId, "template", publication.templateId); + const metadata = await getSyncMetadata(key); + await setSyncMetadata({ + ...metadata, + key, + publicationRevision: publication.revision, + publishedContentHash: publication.sourceContentHash, + isPublished, + }); +} + +export async function refreshPublicationMetadata(): Promise< + Map +> { + const publications = await listOwnPublications(); + const active = new Map(); + for (const publication of publications) { + if (!publication.unpublishedAt) { + active.set(publication.templateId, publication); + } + } + const accountId = await getActiveSyncAccountId(); + if (accountId) { + await replacePublicationMetadata( + accountId, + publications.map((publication) => ({ + templateId: publication.templateId, + revision: publication.revision, + contentHash: publication.sourceContentHash, + isPublished: !publication.unpublishedAt, + })), + ); + } + return active; +} + +export async function publishLocalTemplate( + templateId: string, +): Promise { + const syncResult = await syncAccount(); + if (syncResult.failed > 0) { + throw new UserFacingError( + syncResult.firstError ?? "로컬 변경을 먼저 동기화해 주세요.", + ); + } + const activePublications = await refreshPublicationMetadata(); + + const stored = await findTemplateBySyncId(templateId); + if (!stored) throw new UserFacingError("이 기기에서 템플릿을 찾을 수 없습니다."); + const document = await createCloudTemplateDocument(stored); + const previousPublication = activePublications.get(templateId); + const previousHashes = previousPublication + ? assetHashes(previousPublication.snapshot) + : new Set(); + + const accountId = await getActiveSyncAccountId(); + if (!accountId) throw new UserFacingError("Google 로그인이 필요합니다."); + const metadata = await getSyncMetadata( + syncMetadataKey(accountId, "template", templateId), + ); + let hashes: Set; + let publication: TemplatePublication; + try { + hashes = await uploadPublishedAssets(templateId, document, previousHashes); + const { data, error } = await getSupabaseClient().rpc("publish_template", { + p_template_id: templateId, + p_expected_revision: metadata?.publicationRevision, + }); + if (error) throw toSupabaseUserError(error, "템플릿을 게시하지 못했습니다."); + publication = mapOwnPublication(data); + } catch (error) { + try { + await removeUnreferencedPublicAssets(templateId, previousHashes); + } catch { + recordBreadcrumb( + "community.publish", + "failed upload cleanup was unavailable", + undefined, + "warning", + ); + } + throw error; + } + + try { + await savePublicationMetadata(publication, true); + } catch { + recordBreadcrumb( + "community.publish", + "published metadata will be refreshed later", + undefined, + "warning", + ); + } + try { + await removeUnreferencedPublicAssets(templateId, hashes); + } catch { + recordBreadcrumb( + "community.publish", + "stale public asset cleanup was unavailable", + undefined, + "warning", + ); + } + return publication; +} + +export async function unpublishLocalTemplate( + templateId: string, +): Promise { + await refreshPublicationMetadata(); + const accountId = await getActiveSyncAccountId(); + if (!accountId) throw new UserFacingError("Google 로그인이 필요합니다."); + const metadata = await getSyncMetadata( + syncMetadataKey(accountId, "template", templateId), + ); + if (!metadata?.publicationRevision) { + throw new UserFacingError("게시 상태를 다시 확인해 주세요."); + } + + const { data, error } = await getSupabaseClient().rpc("unpublish_template", { + p_template_id: templateId, + p_expected_revision: metadata.publicationRevision, + }); + if (error) throw toSupabaseUserError(error, "게시를 내리지 못했습니다."); + try { + await savePublicationMetadata(mapOwnPublication(data), false); + } catch { + recordBreadcrumb( + "community.unpublish", + "unpublished metadata will be refreshed later", + undefined, + "warning", + ); + } + try { + await removeUnreferencedPublicAssets(templateId, new Set()); + } catch { + recordBreadcrumb( + "community.unpublish", + "public asset cleanup was unavailable", + undefined, + "warning", + ); + } +} + +export async function setPublicationLiked( + templateId: string, + liked: boolean, +): Promise { + const { data, error } = await getSupabaseClient().rpc("set_publication_liked", { + p_template_id: templateId, + p_liked: liked, + }); + if (error) throw toSupabaseUserError(error, "좋아요를 저장하지 못했습니다."); + return data; +} + +async function resolvePublicAsset(templateId: string, hash: string, name: string) { + const existing = await getAssetById(hash); + if (existing) return existing; + const { data, error } = await getSupabaseClient().storage + .from(PUBLIC_BUCKET) + .download(publicAssetPath(templateId, hash)); + if (error) { + throw toSupabaseStorageError(error, "게시 아이콘을 내려받지 못했습니다."); + } + return saveImportedCloudAsset(name, data, hash); +} + +export async function clonePublication( + publication: TemplatePublication, +): Promise { + const template = await publishedSnapshotToTemplate( + publication.snapshot, + (hash, name) => resolvePublicAsset(publication.templateId, hash, name), + ); + const stored = await importTemplateCopy(template); + + void recordSignedInClone(publication.templateId); + return stored.template.templateId; +} + +async function recordSignedInClone(templateId: string): Promise { + try { + if (!(await getGoogleAccountId())) return; + const { error } = await getSupabaseClient().rpc( + "record_publication_clone", + { p_template_id: templateId }, + ); + if (error) { + recordBreadcrumb( + "community.clone", + "clone counter was not recorded", + undefined, + "warning", + ); + } + } catch { + recordBreadcrumb( + "community.clone", + "clone counter request was unavailable", + undefined, + "warning", + ); + } +} + +export async function isPublicationOutdated( + stored: StoredTemplate, + publishedContentHash?: string, +): Promise { + if (!publishedContentHash) return false; + const document = await createCloudTemplateDocument(stored); + return (await hashPublishedTemplate(document)) !== publishedContentHash; +} + +async function removeStorageFolder( + bucketName: string, + folder: string, +): Promise { + const bucket = getSupabaseClient().storage.from(bucketName); + while (true) { + const { data, error } = await bucket.list(folder, { limit: 100 }); + if (error) { + throw toSupabaseStorageError(error, "클라우드 아이콘을 정리하지 못했습니다."); + } + const paths = data.map((file) => `${folder}/${file.name}`); + if (paths.length === 0) return; + const { error: removeError } = await bucket.remove(paths); + if (removeError) { + throw toSupabaseStorageError(removeError, "클라우드 아이콘을 정리하지 못했습니다."); + } + } +} + +export async function clearLinkuCloudData(): Promise { + const client = getSupabaseClient(); + const { data: sessionData, error: sessionError } = await client.auth.getSession(); + if (sessionError) { + throw toSupabaseAuthError(sessionError, "계정 정보를 불러오지 못했습니다."); + } + const userId = sessionData.session?.user.id; + if (!userId) throw new UserFacingError("Google 로그인이 필요합니다."); + + const { data: templates, error: templatesError } = await client + .from("templates") + .select("id"); + if (templatesError) { + throw toSupabaseUserError( + templatesError, + "클라우드 템플릿 목록을 불러오지 못했습니다.", + ); + } + + const publications = await listOwnPublications(); + for (const publication of publications) { + if (publication.unpublishedAt) continue; + const { error: unpublishError } = await client.rpc("unpublish_template", { + p_template_id: publication.templateId, + p_expected_revision: publication.revision, + }); + if (unpublishError) { + throw toSupabaseUserError( + unpublishError, + "게시물을 비공개로 전환하지 못했습니다.", + ); + } + } + + await removeStorageFolder("template-assets", userId); + for (const template of templates) { + await removeStorageFolder(PUBLIC_BUCKET, template.id); + } + + const { error } = await client.rpc("clear_linku_data"); + if (error) throw toSupabaseUserError(error, "LinKU 클라우드 데이터를 삭제하지 못했습니다."); + await clearCloudSyncState(); +} diff --git a/src/apis/supabase/errors.ts b/src/apis/supabase/errors.ts new file mode 100644 index 00000000..ea66fcbe --- /dev/null +++ b/src/apis/supabase/errors.ts @@ -0,0 +1,69 @@ +import type { AuthError, PostgrestError } from "@supabase/supabase-js"; +import { UserFacingError } from "@/errors/userFacingError"; + +const EXPECTED_MESSAGES: Record = { + ASSET_LIMIT_REACHED: "계정에는 사용자 아이콘을 최대 100개까지 동기화할 수 있습니다.", + GOOGLE_ACCOUNT_REQUIRED: "Google 계정으로 로그인해 주세요.", + INVALID_NICKNAME: "닉네임은 1자 이상 32자 이하로 입력해 주세요.", + INVALID_TEMPLATE: "템플릿 데이터가 올바르지 않습니다.", + LOGIN_REQUIRED: "Google 로그인이 필요합니다.", + PUBLICATION_ACTIVE: "게시 중인 템플릿은 게시를 내린 뒤 삭제해 주세요.", + PUBLICATION_LIMIT_REACHED: "계정당 최대 25개의 템플릿을 게시할 수 있습니다.", + PUBLICATION_NOT_FOUND: "게시물을 찾을 수 없습니다.", + TEMPLATE_LIMIT_REACHED: "계정에는 템플릿을 최대 100개까지 동기화할 수 있습니다.", + TEMPLATE_NOT_FOUND: "동기화된 템플릿을 찾을 수 없습니다.", +}; + +export class SyncConflictError extends UserFacingError { + constructor() { + super("다른 기기의 변경과 겹쳤습니다.", "SYNC_CONFLICT"); + this.name = "SyncConflictError"; + } +} + +export function toSupabaseUserError( + error: PostgrestError, + fallback: string, +): Error { + if (error.code === "40001" || error.message === "LINKU_CONFLICT") { + return new SyncConflictError(); + } + const message = EXPECTED_MESSAGES[error.message]; + return message + ? new UserFacingError(message, error.message) + : Object.assign(new Error(fallback), { code: error.code }); +} + +export function toSupabaseAuthError( + error: AuthError, + fallback: string, +): Error { + const status = Number(error.status); + const code = typeof error.code === "string" ? error.code : undefined; + if (Number.isFinite(status) && status >= 400 && status < 500) { + return new UserFacingError(fallback, code ?? "AUTH_REQUEST_FAILED"); + } + return Object.assign(new Error(fallback), { + status: Number.isFinite(status) ? status : undefined, + code, + }); +} + +export function toSupabaseStorageError( + error: unknown, + fallback: string, +): Error { + const candidate = error as { + code?: unknown; + status?: unknown; + statusCode?: unknown; + }; + const status = Number(candidate?.status ?? candidate?.statusCode); + const code = + typeof candidate?.code === "string" ? candidate.code : undefined; + + if (Number.isFinite(status) && status >= 500) { + return Object.assign(new Error(fallback), { status, code }); + } + return new UserFacingError(fallback, code ?? "STORAGE_REQUEST_FAILED"); +} diff --git a/src/apis/supabase/templates.ts b/src/apis/supabase/templates.ts new file mode 100644 index 00000000..97abd3ac --- /dev/null +++ b/src/apis/supabase/templates.ts @@ -0,0 +1,175 @@ +import { getSupabaseClient } from "@/apis/supabase/client"; +import { + toSupabaseAuthError, + toSupabaseStorageError, + toSupabaseUserError, +} from "@/apis/supabase/errors"; +import type { StoredAsset } from "@/storage/indexedDb/linkuDatabase"; +import type { + CloudTemplateDocumentV1, + RemoteTemplate, +} from "@/types/account"; +import type { Database, Json } from "@/types/supabase"; +import { parseCloudTemplateDocument } from "@/sync/templateDocument"; +import { recordBreadcrumb } from "@/monitoring"; +import { UserFacingError } from "@/errors/userFacingError"; + +type TemplateRow = Database["public"]["Tables"]["templates"]["Row"]; +type AssetRow = Database["public"]["Tables"]["template_assets"]["Row"]; + +export interface RemoteAsset { + contentHash: string; + name: string; + objectPath: string; + byteSize: number; +} + +function mapTemplate(row: TemplateRow): RemoteTemplate { + return { + id: row.id, + document: parseCloudTemplateDocument(row.document), + contentHash: row.content_hash, + revision: row.revision, + deletedAt: row.deleted_at, + updatedAt: row.updated_at, + }; +} + +function mapAsset(row: AssetRow): RemoteAsset { + return { + contentHash: row.content_hash, + name: row.name, + objectPath: row.object_path, + byteSize: row.byte_size, + }; +} + +export async function listRemoteTemplates(): Promise { + const { data, error } = await getSupabaseClient() + .from("templates") + .select("id, document, content_hash, revision, deleted_at, updated_at") + .order("updated_at", { ascending: true }); + if (error) throw toSupabaseUserError(error, "템플릿을 동기화하지 못했습니다."); + return data.map((row) => mapTemplate(row as TemplateRow)); +} + +export async function getRemoteTemplate(id: string): Promise { + const { data, error } = await getSupabaseClient() + .from("templates") + .select("id, document, content_hash, revision, deleted_at, updated_at") + .eq("id", id) + .maybeSingle(); + if (error) throw toSupabaseUserError(error, "템플릿을 동기화하지 못했습니다."); + return data ? mapTemplate(data as TemplateRow) : null; +} + +export async function putRemoteTemplate( + id: string, + document: CloudTemplateDocumentV1, + contentHash: string, + expectedRevision?: number, +): Promise { + const { data, error } = await getSupabaseClient().rpc("put_template", { + p_id: id, + p_document: document as unknown as Json, + p_content_hash: contentHash, + p_expected_revision: expectedRevision, + }); + if (error) throw toSupabaseUserError(error, "템플릿을 저장하지 못했습니다."); + return mapTemplate(data); +} + +export async function deleteRemoteTemplate( + id: string, + expectedRevision: number, +): Promise { + const { data, error } = await getSupabaseClient().rpc("delete_template", { + p_id: id, + p_expected_revision: expectedRevision, + }); + if (error) throw toSupabaseUserError(error, "템플릿 삭제를 동기화하지 못했습니다."); + return mapTemplate(data); +} + +export async function listRemoteAssets(): Promise { + const { data, error } = await getSupabaseClient() + .from("template_assets") + .select("content_hash, name, object_path, byte_size, owner_id, created_at"); + if (error) throw toSupabaseUserError(error, "아이콘 목록을 불러오지 못했습니다."); + return data.map(mapAsset); +} + +async function currentUserId(): Promise { + const { data, error } = await getSupabaseClient().auth.getSession(); + if (error) { + throw toSupabaseAuthError(error, "계정 정보를 불러오지 못했습니다."); + } + const userId = data.session?.user.id; + if (!userId) throw new UserFacingError("Google 로그인이 필요합니다.", "LOGIN_REQUIRED"); + return userId; +} + +export async function uploadRemoteAsset(asset: StoredAsset): Promise { + const client = getSupabaseClient(); + const userId = await currentUserId(); + const objectPath = `${userId}/${asset.id}.webp`; + const { error: uploadError } = await client.storage + .from("template-assets") + .upload(objectPath, asset.blob, { + cacheControl: "31536000", + contentType: "image/webp", + upsert: true, + }); + if (uploadError) { + throw toSupabaseStorageError(uploadError, "아이콘을 동기화하지 못했습니다."); + } + + const { data, error } = await client + .from("template_assets") + .upsert( + { + content_hash: asset.id, + name: asset.name, + object_path: objectPath, + byte_size: asset.blob.size, + }, + { onConflict: "owner_id,content_hash" }, + ) + .select("content_hash, name, object_path, byte_size, owner_id, created_at") + .single(); + if (error) { + const { data: persisted, error: readbackError } = await client + .from("template_assets") + .select("content_hash, name, object_path, byte_size, owner_id, created_at") + .eq("content_hash", asset.id) + .maybeSingle(); + if (persisted) return mapAsset(persisted); + + if (!readbackError) { + const { error: cleanupError } = await client.storage + .from("template-assets") + .remove([objectPath]); + if (!cleanupError) { + throw toSupabaseUserError(error, "아이콘을 동기화하지 못했습니다."); + } + recordBreadcrumb( + "account.sync", + "failed private asset cleanup was unavailable", + undefined, + "warning", + ); + } + throw toSupabaseUserError(error, "아이콘을 동기화하지 못했습니다."); + } + return mapAsset(data); +} + +export async function downloadRemoteAsset(asset: RemoteAsset): Promise { + const { data, error } = await getSupabaseClient().storage + .from("template-assets") + .download(asset.objectPath); + if (error) { + throw toSupabaseStorageError(error, "아이콘을 내려받지 못했습니다."); + } + return data; +} diff --git a/src/background/handlers/oauth.ts b/src/background/handlers/oauth.ts index 163fbbb5..5b8fa392 100644 --- a/src/background/handlers/oauth.ts +++ b/src/background/handlers/oauth.ts @@ -1,291 +1,135 @@ -/** - * 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 { getAccountProfile } from "@/apis/supabase/account"; +import { toSupabaseAuthError } from "@/apis/supabase/errors"; import { - debugLog, - captureErrorLog, - getHttpErrorLogDetails, - captureWarnLog, - warnLog, -} from "@/utils/logger"; + clearLegacyAuthStorage, + getSupabaseClient, + SupabaseConfigurationError, +} from "@/apis/supabase/client"; import { recordBreadcrumb } from "@/monitoring"; +import { captureErrorLog, captureWarnLog, debugLog } from "@/utils/logger"; -// Backend URL from environment -const BACKEND_URL = (() => { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - if (!baseUrl) return ""; - - 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; - } 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; - } - await chrome.storage.local.set(data); +function isUserCancellation(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /did not approve|closed|cancell?ed|interrupted|access_denied/iu.test( + message, + ); } -/** - * Handle Google OAuth Login - * Uses chrome.identity.launchWebAuthFlow for OAuth flow - */ -export async function handleGoogleLogin(): Promise { - try { - debugLog("[Background] Starting Google OAuth flow"); +function expectedCallbackMatches(responseUrl: URL, redirectUri: string): boolean { + const expected = new URL(redirectUri); + return ( + responseUrl.origin === expected.origin && + responseUrl.pathname === expected.pathname + ); +} - // 1. Get extension ID and construct redirect URI - const extensionId = chrome.runtime.id; - const redirectUri = `https://${extensionId}.chromiumapp.org/`; +let activeLogin: Promise | null = null; - if (!BACKEND_URL) { - // A build shipped without a backend URL cannot log anyone in, and the - // user only sees a generic retry message. Record it so the broken - // configuration is visible instead of looking like a transient outage. - captureWarnLog("[Background] OAuth unavailable: backend URL is not configured"); - return { - success: false, - error: "로그인 기능을 사용할 수 없습니다. 잠시 후 다시 시도해주세요.", - }; +async function performGoogleLogin(): Promise { + try { + const client = getSupabaseClient(); + const redirectUri = chrome.identity.getRedirectURL("supabase"); + const { data, error } = await client.auth.signInWithOAuth({ + provider: "google", + options: { + redirectTo: redirectUri, + skipBrowserRedirect: true, + }, + }); + if (error) { + throw toSupabaseAuthError(error, "Google 로그인을 시작하지 못했습니다."); } + if (!data.url) throw new Error("OAUTH_URL_MISSING"); - // 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(), + const response = await chrome.identity.launchWebAuthFlow({ + url: data.url, interactive: true, }); - - if (!responseUrl) { - // launchWebAuthFlow resolves without a URL both when the user closes the - // window and when the flow ends without a redirect, so this is not - // necessarily a cancellation and must not disappear silently. + if (!response) { recordBreadcrumb( "oauth.outcome", "OAuth flow ended without a redirect URL", undefined, "info", ); - warnLog("[Background] OAuth flow returned no redirect URL"); - return { success: false, error: "인증이 취소되었습니다." }; + 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 ? "있음" : "없음"); + const callback = new URL(response); + if (!expectedCallbackMatches(callback, redirectUri)) { + captureErrorLog("[Background] OAuth callback origin verification failed"); + return { success: false, error: "로그인 응답을 확인하지 못했습니다." }; + } - if (error) { - const isExpectedAuthOutcome = /access_denied|cancel|closed/iu.test(error); + const providerError = callback.searchParams.get("error"); + if (providerError) { + const expected = /access_denied|cancel|closed/iu.test(providerError); recordBreadcrumb( "oauth.outcome", "OAuth provider returned an error outcome", - { oauth_error: error, expected: isExpectedAuthOutcome }, - isExpectedAuthOutcome ? "info" : "error", + { oauth_error: providerError, expected }, + expected ? "info" : "error", ); - if (isExpectedAuthOutcome) { - warnLog("[Background] OAuth was not approved", { error }); - return { success: false, error: "인증이 취소되었습니다." }; + if (expected) { + return { success: false, error: "사용자가 인증을 취소했습니다." }; } captureErrorLog( - "[Background] OAuth error returned from provider", - new Error(`OAuth provider error: ${error}`), - { oauth_error: error }, + "[Background] OAuth provider returned an unexpected error", + new Error(`OAuth provider error: ${providerError}`), ); - return { - success: false, - error: "인증 제공자가 로그인을 완료하지 못했습니다.", - }; + return { success: false, error: "Google 로그인을 완료하지 못했습니다." }; } + const code = callback.searchParams.get("code"); if (!code) { - captureErrorLog("[Background] OAuth response did not include an authorization code"); - return { - success: false, - error: "인증 코드를 받지 못했습니다.", - }; + captureErrorLog("[Background] OAuth callback did not include a code"); + 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", - }, - }); - - debugLog("[Background] Token Response Status:", tokenResponse.status); - - if (!tokenResponse.ok) { - const errorBody = await tokenResponse.text(); - captureErrorLog( - "[Background] Token exchange failed", - getHttpErrorLogDetails( - tokenResponse.status, - tokenResponse.statusText, - errorBody, - ), - ); - return { - success: false, - error: "로그인 정보를 확인하지 못했습니다. 잠시 후 다시 시도해주세요.", - }; + const { error: exchangeError } = await client.auth.exchangeCodeForSession(code); + if (exchangeError) { + throw toSupabaseAuthError(exchangeError, "Google 로그인을 완료하지 못했습니다."); } - - const tokenData = await tokenResponse.json(); - - // 6. Parse backend response - // 응답 형식: { code: 1000, message: "SUCCESS", result: { accessToken, refreshToken } } - if (tokenData.code !== 1000) { - captureErrorLog("[Background] Backend rejected token exchange", { - status: tokenResponse.status, - code: tokenData.code, - message: tokenData.message, - }); - return { - success: false, - error: "로그인 정보를 처리하지 못했습니다. 잠시 후 다시 시도해주세요.", - }; + const profile = await getAccountProfile(); + if (!profile) { + await client.auth.signOut({ scope: "local" }); + throw new Error("GOOGLE_ACCOUNT_REQUIRED"); } - const { accessToken, refreshToken } = tokenData.result || {}; - - if (!accessToken) { - captureErrorLog("[Background] No accessToken in OAuth response", { - status: tokenResponse.status, - code: tokenData.code, - }); - return { - success: false, - error: "로그인 응답을 처리하지 못했습니다. 잠시 후 다시 시도해주세요.", - }; - } - - // 7. Save tokens - await saveTokens(accessToken, refreshToken); - debugLog("[Background] Tokens saved successfully"); - - // 8. Return success response - // refreshToken이 없으면 게스트(신규 회원) - const isGuest = !refreshToken; - - return { - success: true, - response: { - guestToken: accessToken, - requiresSignup: isGuest, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), - profile: { - email: "", - name: "", - picture: "", - }, - }, - }; + await clearLegacyAuthStorage(); + debugLog("[Background] Google session established"); + return { success: true, profile }; } 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")); - - if (isUserCancellation) { + if (isUserCancellation(error)) { recordBreadcrumb( "oauth.outcome", "OAuth flow cancelled by user", undefined, "info", ); - debugLog("[Background] OAuth cancelled by user", { - message: error instanceof Error ? error.message : String(error), - }); - } else { - captureErrorLog("[Background] OAuth error", error); + return { success: false, error: "사용자가 인증을 취소했습니다." }; } - - // 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: "로그인이 중단되었습니다.", - }; - } + if (error instanceof SupabaseConfigurationError) { + captureWarnLog("[Background] Supabase auth is not configured"); + return { + success: false, + error: "로그인 기능을 준비 중입니다.", + }; } - + captureErrorLog("[Background] Google OAuth failed", error); return { success: false, - error: "로그인에 실패했습니다. 잠시 후 다시 시도해주세요.", + error: "로그인을 완료하지 못했습니다. 잠시 후 다시 시도해 주세요.", }; } } + +export function handleGoogleLogin(): Promise { + if (!activeLogin) { + activeLogin = performGoogleLogin().finally(() => { + activeLogin = null; + }); + } + return activeLogin; +} diff --git a/src/background/index.ts b/src/background/index.ts index bc3faeb6..8f931a39 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -12,7 +12,6 @@ import { BackgroundMessageType, isAnalyticsBatchMessage, isGoogleLoginMessage, - isSilentReauthMessage, isTimetableImportMessage, } from "./types"; import { @@ -25,7 +24,6 @@ import type { BackgroundMessage, AnalyticsTransportResponse, GoogleLoginResponse, - SilentReauthResponse, TimetableImportResponse, } from "./types"; import { handleGoogleLogin } from "./handlers/oauth"; @@ -54,10 +52,7 @@ import { } from "@/monitoring"; import { getUserFacingErrorMessage, - UserFacingError, } from "@/errors/userFacingError"; -import { enqueuePendingTemplateImport } from "@/utils/pendingTemplateImports"; -import type { TemplateShareImportResponse } from "@/types/templateShare"; import { deliverAnalyticsPayload } from "@/utils/analyticsTransport"; initMonitoring("background"); @@ -247,45 +242,11 @@ chrome.runtime.onMessage.addListener( return response; }, respond, - fallback: (error) => ({ - success: false, - error: getUserFacingErrorMessage( - error, - "로그인에 실패했습니다. 잠시 후 다시 시도해주세요.", - ), - }), - }); - } - - // Handle Silent Reauth (when token expires - 5004 error) - if (isSilentReauthMessage(typedMessage)) { - debugLog( - "[Background] Handling silent reauth request (token expired)", - ); - - return runAsyncMessageHandler({ - feature: "silent_reauth", - messageType, - failureLog: "[Background] Silent reauth error", - handle: async () => { - const response = await handleGoogleLogin(); - debugLog( - "[Background] Silent reauth completed:", - response.success, - ); - return { - success: response.success, - error: response.success - ? undefined - : (response as { error?: string }).error, - }; - }, - respond, fallback: (error) => ({ success: false, error: getUserFacingErrorMessage( error, - "재인증에 실패했습니다. 다시 로그인해주세요.", + "로그인에 실패했습니다. 잠시 후 다시 시도해주세요.", ), }), }); @@ -299,12 +260,12 @@ chrome.runtime.onMessage.addListener( handle: () => handleTimetableImport(typedMessage.data?.mode), respond, fallback: (error) => ({ - success: false, - code: "UNKNOWN", - error: getUserFacingErrorMessage( - error, - "시간표를 가져오지 못했습니다. 잠시 후 다시 시도해주세요.", - ), + success: false, + code: "UNKNOWN", + error: getUserFacingErrorMessage( + error, + "시간표를 가져오지 못했습니다. 잠시 후 다시 시도해주세요.", + ), }), }); } @@ -367,45 +328,6 @@ chrome.runtime.onMessage.addListener( }, ); -chrome.runtime.onMessageExternal.addListener( - ( - message: unknown, - sender: chrome.runtime.MessageSender, - sendResponse: (response: TemplateShareImportResponse) => void, - ) => { - if ( - sender.origin !== "https://turtle-hwan.github.io" || - !sender.url?.startsWith("https://turtle-hwan.github.io/LinKU/share/") || - !message || - typeof message !== "object" || - (message as { type?: unknown }).type !== "IMPORT_SHARED_TEMPLATE" - ) { - sendResponse({ success: false, error: "허용되지 않은 가져오기 요청입니다." }); - return false; - } - - const payload = (message as { data?: { payload?: unknown } }).data?.payload; - - void enqueuePendingTemplateImport(payload) - .then((result) => - sendResponse({ - success: true, - alreadyQueued: result === "already-queued" || undefined, - }), - ) - .catch((error: unknown) => { - if (!(error instanceof UserFacingError)) { - reportBackgroundException(error, "shared_template_import"); - } - sendResponse({ - success: false, - error: getUserFacingErrorMessage(error, "템플릿을 가져오지 못했습니다."), - }); - }); - return true; - }, -); - /** * Extension install/update handler */ @@ -536,7 +458,6 @@ export type { AnalyticsTransportResponse, BackgroundMessage, GoogleLoginResponse, - SilentReauthResponse, TimetableImportResponse, }; export { BackgroundMessageType }; diff --git a/src/background/types.ts b/src/background/types.ts index e3ff3bb8..03f8cca0 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 { AccountProfile } from '../types/account'; import type { TimetableImportMode, TimetableImportResponse, @@ -19,7 +19,6 @@ import type { AnalyticsTransportResponse } from '../utils/analyticsTransport.ts' */ export enum BackgroundMessageType { GOOGLE_LOGIN = 'GOOGLE_LOGIN', - SILENT_REAUTH = 'SILENT_REAUTH', TIMETABLE_IMPORT = 'TIMETABLE_IMPORT', ANALYTICS_BATCH = 'ANALYTICS_BATCH', } @@ -44,7 +43,7 @@ export interface GoogleLoginMessage extends BackgroundMessage { */ export interface GoogleLoginSuccessResponse { success: true; - response: GoogleOAuthResponse; + profile: AccountProfile; } /** @@ -69,31 +68,6 @@ export function isGoogleLoginMessage( return message.type === BackgroundMessageType.GOOGLE_LOGIN; } -/** - * Silent Reauth Request Message - * Used when token expires (5004 error) - triggers OAuth without user interaction - */ -export interface SilentReauthMessage extends BackgroundMessage { - type: BackgroundMessageType.SILENT_REAUTH; -} - -/** - * Silent Reauth Response - */ -export interface SilentReauthResponse { - success: boolean; - error?: string; -} - -/** - * Type guard for Silent Reauth Message - */ -export function isSilentReauthMessage( - message: BackgroundMessage -): message is SilentReauthMessage { - return message.type === BackgroundMessageType.SILENT_REAUTH; -} - /** * Requests a one-off import from an existing Everytime timetable tab. * When no matching tab exists, the background worker opens one temporarily. diff --git a/src/components/Editor/EditorHeader/EditorHeader.tsx b/src/components/Editor/EditorHeader/EditorHeader.tsx index 5f00a67c..35ab35e3 100644 --- a/src/components/Editor/EditorHeader/EditorHeader.tsx +++ b/src/components/Editor/EditorHeader/EditorHeader.tsx @@ -4,7 +4,7 @@ import { Input } from '@/components/ui/input'; import { BackButton } from './BackButton'; import { SaveButton } from './SaveButton'; import { useEditorContext } from '@/hooks/useEditorContext'; -import { saveLocalTemplate } from '@/utils/templateStorage'; +import { saveLocalTemplate } from '@/storage/templates/repository'; import { toast } from 'sonner'; import { captureErrorLog } from '@/utils/logger'; import { @@ -54,6 +54,7 @@ export const EditorHeader = () => { toast.success('저장 완료', { description: '이 기기에 저장했습니다.', }); + window.dispatchEvent(new Event('linku:templates-changed')); if (isFirstSave) { navigate(`/editor/${savedTemplate.templateId}`, { replace: true }); } diff --git a/src/components/Editor/TemplatePreview/TemplateCard.tsx b/src/components/Editor/TemplatePreview/TemplateCard.tsx index c6a05e25..219515f2 100644 --- a/src/components/Editor/TemplatePreview/TemplateCard.tsx +++ b/src/components/Editor/TemplatePreview/TemplateCard.tsx @@ -1,7 +1,7 @@ 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, Loader2, Trash2 } from 'lucide-react'; import { UNSAVED_TEMPLATE_ID } from '@/constants/template'; interface TemplateCardProps { @@ -11,7 +11,6 @@ interface TemplateCardProps { isSelected?: boolean; onApply?: (event: React.MouseEvent) => void; onDelete?: (event: React.MouseEvent) => void; - onShare?: (event: React.MouseEvent) => void; showDelete?: boolean; isActionLoading?: boolean; } @@ -23,7 +22,6 @@ export const TemplateCard = ({ isSelected, onApply, onDelete, - onShare, showDelete = false, isActionLoading = false, }: TemplateCardProps) => ( @@ -41,68 +39,49 @@ export const TemplateCard = ({ )} -
-

{template.name}

-
- {template.itemCount || 0} items - {template.height}행 -
-
- -
- {isActionLoading && ( -
- +
+
+

{template.name}

+
+ {template.itemCount || 0} items + {template.height}행
- )} - {onShare && !isActionLoading && ( - - )} - {template.templateId !== UNSAVED_TEMPLATE_ID && ( -
- +
+ {(isActionLoading || onApply || (showDelete && onDelete)) && ( +
+ {isActionLoading && } + {onApply && + (isSelected ? ( +
+ +
+ ) : ( + + ))} + {showDelete && + template.templateId !== UNSAVED_TEMPLATE_ID && + onDelete && ( + + )}
)} - {onApply && - (isSelected ? ( -
- -
- ) : ( - - ))} - {showDelete && template.templateId !== UNSAVED_TEMPLATE_ID && onDelete && ( - - )}
); diff --git a/src/components/EmailVerificationDialog.tsx b/src/components/EmailVerificationDialog.tsx deleted file mode 100644 index edab04c3..00000000 --- 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 { captureErrorLog } 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) { - captureErrorLog('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) { - captureErrorLog('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) { - captureErrorLog('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 525d8e68..afdd23a5 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import UtilityDialog from "@/components/UtilityDialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Input } from "@/components/ui/input"; import { usePersistentDialogTab } from "@/hooks/usePersistentDialogTab"; import { @@ -23,23 +22,28 @@ import { import { startGoogleLogin, logout, - isLoggedIn, getUserProfile, - isGuestUser, - UserProfile, + isLoggedIn, + type UserProfile, } from "@/utils/oauth"; +import { updateAccountNickname } from "@/apis/supabase/account"; +import { clearLinkuCloudData } from "@/apis/supabase/community"; +import { SupabaseConfigurationError } from "@/apis/supabase/client"; +import { + getActiveSyncAccountId, + resetSyncConnection, +} from "@/storage/account/syncRepository"; import { Info, Palette, LogOut, - Mail, Settings as SettingsIcon, Timer, + Trash2, 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 { @@ -53,6 +57,9 @@ import { refreshTodoCount, } from "@/utils/todo/count"; import { captureErrorLog } from '@/utils/logger'; +import { UserFacingError } from '@/errors/userFacingError'; +import { isExpectedNetworkFailure } from '@/utils/networkFailure'; +import { recordBreadcrumb } from '@/monitoring'; import { eCampusCredentialsSchema, getFirstValidationMessage, @@ -63,6 +70,30 @@ interface SettingsDialogProps { onOpenChange: (open: boolean) => void; } +function reportAccountFailure(message: string, error: unknown) { + if ( + error instanceof UserFacingError || + error instanceof SupabaseConfigurationError || + isExpectedNetworkFailure(error) + ) { + recordBreadcrumb( + 'account.settings', + message, + { + reason: + error instanceof UserFacingError + ? error.code + : error instanceof SupabaseConfigurationError + ? 'not_configured' + : 'network', + }, + 'warning', + ); + return; + } + captureErrorLog(message, error); +} + const SETTINGS_TABS = ["google", "ecampus"] as const; const LAST_SETTINGS_TAB_STORAGE_KEY = "ui:lastSettingsTab:v1"; @@ -265,58 +296,54 @@ const ECampusCredential = () => { const GoogleOAuthSection = () => { const [loggedIn, setLoggedIn] = useState(false); - const [isGuest, setIsGuest] = useState(false); const [userProfile, setUserProfile] = useState(null); + const [nickname, setNickname] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [showEmailVerification, setShowEmailVerification] = useState(false); - const [verifiedEmail, setVerifiedEmail] = useState(null); + const [isDeletingCloud, setIsDeletingCloud] = useState(false); + const [hasSyncBinding, setHasSyncBinding] = useState(false); // Check login status on mount useEffect(() => { - checkLoginStatus(); + void checkLoginStatus().catch((error) => { + reportAccountFailure("[Settings] Failed to load account profile", error); + }); }, []); // Listen for auth events useEffect(() => { const handleLogout = () => { setLoggedIn(false); - setIsGuest(false); setUserProfile(null); - setVerifiedEmail(null); + setNickname(""); }; - - const handleUnauthorized = () => { - setLoggedIn(false); - setIsGuest(false); - setUserProfile(null); + const handleLogin = (event: Event) => { + const profile = (event as CustomEvent).detail; + setLoggedIn(true); + setUserProfile(profile); + setNickname(profile.nickname); }; window.addEventListener('auth:logout', handleLogout); - window.addEventListener('auth:unauthorized', handleUnauthorized); + window.addEventListener('auth:login', handleLogin); return () => { window.removeEventListener('auth:logout', handleLogout); - window.removeEventListener('auth:unauthorized', handleUnauthorized); + window.removeEventListener('auth:login', handleLogin); }; }, []); const checkLoginStatus = async () => { - const loggedIn = await isLoggedIn(); - 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); - } - } + const [connected, boundAccountId] = await Promise.all([ + isLoggedIn(), + getActiveSyncAccountId(), + ]); + setHasSyncBinding(boundAccountId !== null); + setLoggedIn(connected); + if (!connected) return; + const profile = await getUserProfile(); + if (!profile) return; + setUserProfile(profile); + setNickname(profile.nickname); }; const handleGoogleLogin = async () => { @@ -328,20 +355,11 @@ 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("로그인 성공!"); - } + setHasSyncBinding(true); + setUserProfile(result.profile); + setNickname(result.profile.nickname); + sendAuthLoginSuccess("google", false); + toast.success("로그인했습니다."); } else { sendAuthLoginFail("google", "login_failed", result.error || "알 수 없는 오류"); toast.error("로그인 실패", { @@ -360,72 +378,100 @@ const GoogleOAuthSection = () => { } }; - // Called after email verification is complete - const handleVerificationComplete = async () => { - // Re-login to get member token - setIsLoading(true); + const handleLogout = async () => { + sendAuthLogout("settings_dialog"); 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); - } + await logout(); + toast.success("로그아웃 완료"); + } catch (error) { + reportAccountFailure('[Settings] Failed to sign out', error); + toast.error('서버 로그아웃을 완료하지 못했지만 이 기기의 세션은 지웠습니다.'); + } finally { + setLoggedIn(false); + setUserProfile(null); + setNickname(""); + } + }; - toast.success("회원가입 완료!", { - description: "이제 모든 기능을 사용할 수 있습니다.", - }); - } else { - // Still guest after re-login (edge case) - toast.error("인증에 문제가 발생했습니다. 다시 시도해주세요."); - } + const handleNicknameSave = async () => { + setIsLoading(true); + try { + const profile = await updateAccountNickname(nickname); + setUserProfile(profile); + setNickname(profile.nickname); + toast.success("공개 닉네임을 저장했습니다."); } catch (error) { - captureErrorLog("Re-login error:", error); - toast.error("재로그인에 실패했습니다."); + reportAccountFailure("[Settings] Failed to update public nickname", error); + toast.error( + error instanceof Error ? error.message : "닉네임을 저장하지 못했습니다.", + ); } finally { setIsLoading(false); } }; - const handleLogout = async () => { - sendAuthLogout("settings_dialog"); - - await logout(); - setLoggedIn(false); - setIsGuest(false); - setUserProfile(null); - setVerifiedEmail(null); + const handleCloudDataDelete = async () => { + if ( + !confirm( + 'Supabase에 동기화된 템플릿, 사용자 아이콘, 게시물을 모두 삭제하시겠습니까? 이 기기의 로컬 데이터와 LinKU 로그인 계정은 삭제되지 않습니다.', + ) + ) { + return; + } - toast.success("로그아웃 완료"); + setIsDeletingCloud(true); + try { + await clearLinkuCloudData(); + try { + await logout(); + } catch (error) { + reportAccountFailure('[Settings] Failed to sign out after clearing cloud data', error); + } + setLoggedIn(false); + setUserProfile(null); + setNickname(''); + toast.success('LinKU 클라우드 데이터를 삭제했습니다.'); + } catch (error) { + reportAccountFailure('[Settings] Failed to clear cloud data', error); + toast.error( + error instanceof Error + ? error.message + : 'LinKU 클라우드 데이터를 삭제하지 못했습니다.', + ); + } finally { + setIsDeletingCloud(false); + } }; - // Get initials for avatar fallback - const getInitials = (name: string): string => { - if (!name) return "??"; - return name - .split(' ') - .map((word) => word[0]) - .join('') - .toUpperCase() - .slice(0, 2); + const handleSyncBindingReset = async () => { + if ( + !confirm( + '이 기기의 동기화 계정 연결을 초기화하시겠습니까? 로컬 템플릿은 유지되며, 다음에 로그인한 Google 계정으로 동기화됩니다.', + ) + ) { + return; + } + try { + await resetSyncConnection(); + setHasSyncBinding(false); + toast.success('동기화 계정 연결을 초기화했습니다.'); + } catch (error) { + reportAccountFailure('[Settings] Failed to reset account binding', error); + toast.error('동기화 계정 연결을 초기화하지 못했습니다.'); + } }; if (!loggedIn) { // Not logged in - show login button return (
-

Google / Konkuk 계정 연동

+

LinKU 계정 동기화

- 계정 연동을 하면 템플릿을 서버에 저장하고 여러 기기에서 동기화할 수 있습니다. + 로컬 템플릿은 로그인 없이도 계속 쓸 수 있고, Google 로그인 후에는 여러 기기에서 동기화됩니다.

@@ -436,76 +482,35 @@ const GoogleOAuthSection = () => { > {isLoading ? "로그인 중..." : "Google 로그인"} -
-
- ); - } - - // Guest user - show email verification prompt - if (isGuest) { - return ( - <> -
-

이메일 인증 필요

- -
-
- -

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

-
- - - + {hasSyncBinding && ( -
+ )}
- - - +
); } - // Logged in as member - show user profile return (
-

Google / Konkuk 계정 연동

+

LinKU 계정 동기화

- - - - {userProfile?.name ? getInitials(userProfile.name) : } - - +
+ +

- {userProfile?.name || "사용자"} -

-

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

+

Google 계정으로 연결됨

+ +
+ +
+ setNickname(event.target.value)} + disabled={isLoading} + /> + +
+

+ 게시한 템플릿에는 Google 이름이나 이메일 대신 이 닉네임만 표시됩니다. +

+
+ +
+

클라우드 데이터

+

+ 동기화본과 게시물만 삭제합니다. 이 기기의 템플릿과 Google 로그인 계정은 유지됩니다. +

+ +
); @@ -731,7 +782,7 @@ const SettingsDialog = ({ open, onOpenChange }: SettingsDialogProps) => { className="w-full" > - Google / Konkuk 계정 연동 + LinKU 계정 eCampus 계정 diff --git a/src/components/Tabs/Alerts/AlertFilter.tsx b/src/components/Tabs/Alerts/AlertFilter.tsx deleted file mode 100644 index c7e0254f..00000000 --- a/src/components/Tabs/Alerts/AlertFilter.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Button } from "@/components/ui/button"; - -type AlertViewMode = "all" | "my"; - -interface AlertFilterProps { - viewMode: AlertViewMode; - onViewModeChange: (mode: AlertViewMode) => void; - isLoggedIn: boolean; -} - -const AlertFilter = ({ - viewMode, - onViewModeChange, - isLoggedIn, -}: AlertFilterProps) => { - return ( -
- - {isLoggedIn && ( - - )} -
- ); -}; - -export default AlertFilter; diff --git a/src/components/Tabs/Alerts/AlertItem.tsx b/src/components/Tabs/Alerts/AlertItem.tsx index 06f90e72..6c7c2944 100644 --- a/src/components/Tabs/Alerts/AlertItem.tsx +++ b/src/components/Tabs/Alerts/AlertItem.tsx @@ -29,9 +29,6 @@ const categoryColors: Record = { "국제": "bg-cyan-100 text-cyan-700", }; -// 표준 카테고리인지 확인 (학과명인 경우 false) -const standardCategories = new Set(["일반", "학사", "학생", "장학", "취창업", "국제"]); - const AlertItem = ({ alert, searchQuery = "" }: AlertItemProps) => { const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -43,28 +40,12 @@ const AlertItem = ({ alert, searchQuery = "" }: AlertItemProps) => { const handleClick = () => { if (!alert.url) return; - // category 필드가 표준 카테고리 외 값(학과명)이면 학과 공지로 분류 - const isDept = - "department" in alert || - ("category" in alert && !standardCategories.has(alert.category)); - const source = isDept ? "department" : "general"; - const category = isDept - ? ("category" in alert ? String(alert.category) : alert.department.name) - : String(alert.category); - sendAlertsItemOpen(alert.alertId, category, source); + sendAlertsItemOpen(alert.alertId, alert.category, "general"); window.open(alert.url, "_blank"); }; const isClickable = Boolean(alert.url); - // 일반 공지인지 학과 공지인지 구분 - const hasCategory = "category" in alert; - const hasDepartment = "department" in alert; - - // 카테고리가 표준 카테고리인지 학과명인지 확인 - const isStandardCategory = hasCategory && standardCategories.has(alert.category); - const isDepartmentFromCategory = hasCategory && !isStandardCategory; - return (
{ alert.isRead && "opacity-60" )} > - {/* 헤더: 카테고리 또는 학과 */} + {/* 헤더: 공지 카테고리 */}
- {isStandardCategory && ( - - - - )} - {hasDepartment && ( - - - - )} - {isDepartmentFromCategory && ( - - - - )} + + +
{/* 제목 */} diff --git a/src/components/Tabs/Alerts/Alerts.tsx b/src/components/Tabs/Alerts/Alerts.tsx index 9841d20e..c24f6794 100644 --- a/src/components/Tabs/Alerts/Alerts.tsx +++ b/src/components/Tabs/Alerts/Alerts.tsx @@ -1,21 +1,15 @@ -import { useState, useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getAlerts, getCachedAlerts } from "@/apis"; import type { Alert, AlertCategory } from "@/types/api"; import { getStorage, setStorage } from "@/utils/chrome"; -import { isLoggedIn as checkLoggedIn } from "@/utils/oauth"; import { toast } from "sonner"; import AlertItem from "./AlertItem"; -import AlertFilter from "./AlertFilter"; import AlertSearch from "./AlertSearch"; -import MyAlertsView from "./MyAlertsView"; import { Badge } from "@/components/ui/badge"; -import { captureErrorLog } from '@/utils/logger'; -import { sendAlertsView } from '@/utils/analytics'; +import { captureErrorLog } from "@/utils/logger"; +import { sendAlertsView } from "@/utils/analytics"; import { matchesAlertQuery } from "./alertSearchUtils"; -type AlertViewMode = "all" | "my"; - -const ALERT_VIEW_MODE_KEY = "alertViewMode"; const ALERT_CATEGORY_KEY = "alertCategory"; const categories: { value: AlertCategory | undefined; label: string }[] = [ @@ -32,67 +26,54 @@ const Alerts = () => { const [isLoading, setIsLoading] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [alerts, setAlerts] = useState([]); - const [viewMode, setViewMode] = useState("all"); - const [selectedCategory, setSelectedCategory] = useState(undefined); - const [loggedIn, setLoggedIn] = useState(false); + const [selectedCategory, setSelectedCategory] = useState< + AlertCategory | undefined + >(); const [searchQuery, setSearchQuery] = useState(""); const filteredAlerts = useMemo( () => alerts.filter((alert) => matchesAlertQuery(alert, searchQuery)), - [alerts, searchQuery] + [alerts, searchQuery], ); const hasSearchQuery = searchQuery.trim().length > 0; - // 초기화: 설정 + 로그인 상태를 한 번에 로드 useEffect(() => { - const initialize = async () => { - const [savedViewMode, savedCategory, loginStatus] = await Promise.all([ - getStorage(ALERT_VIEW_MODE_KEY), - getStorage(ALERT_CATEGORY_KEY), - checkLoggedIn(), - ]); - - setLoggedIn(loginStatus); - - // 로그아웃 상태에서 저장된 viewMode가 "my"면 "all"로 변경 - if (savedViewMode === "my" && !loginStatus) { - setViewMode("all"); - } else if (savedViewMode) { - setViewMode(savedViewMode); - } + let cancelled = false; - if (savedCategory) { - setSelectedCategory(savedCategory); + const initialize = async () => { + let savedCategory: AlertCategory | undefined; + try { + savedCategory = await getStorage(ALERT_CATEGORY_KEY); + } catch (error) { + captureErrorLog("[Alerts] Failed to restore category:", error); + if (!cancelled) { + toast.error("저장된 공지 설정을 불러오지 못했습니다."); + } } - + if (cancelled) return; + setSelectedCategory(savedCategory); setIsInitialized(true); + void sendAlertsView("all", savedCategory ?? "전체"); + }; - const resolvedViewMode = (savedViewMode === "my" && !loginStatus) ? "all" : (savedViewMode || "all"); - const resolvedCategory = savedCategory || "전체"; - sendAlertsView(resolvedViewMode, resolvedCategory); + void initialize(); + return () => { + cancelled = true; }; - initialize(); }, []); - // 캐시를 먼저 표시하고 만료된 source만 뒤에서 갱신한다. useEffect(() => { - if (!isInitialized || viewMode !== "all") { - return; - } - + if (!isInitialized) return; let cancelled = false; const loadAlerts = async () => { - const params = selectedCategory - ? { category: selectedCategory } - : undefined; + const params = selectedCategory ? { category: selectedCategory } : undefined; let hasCachedAlerts = false; setIsLoading(true); try { const cachedAlerts = await getCachedAlerts(params); if (cancelled) return; - if (cachedAlerts.length > 0) { hasCachedAlerts = true; setAlerts(cachedAlerts); @@ -103,7 +84,6 @@ const Alerts = () => { const result = await getAlerts(params); if (cancelled) return; - if (result.success && result.data) { setAlerts(result.data); } else if (!hasCachedAlerts) { @@ -113,63 +93,48 @@ const Alerts = () => { } } catch (error) { if (cancelled) return; - captureErrorLog("Error fetching alerts:", error); if (!hasCachedAlerts) { toast.error("공지사항을 불러오는 중 오류가 발생했습니다."); } } finally { - if (!cancelled) { - setIsLoading(false); - } + if (!cancelled) setIsLoading(false); } }; void loadAlerts(); - return () => { cancelled = true; }; - }, [isInitialized, selectedCategory, viewMode]); + }, [isInitialized, selectedCategory]); - // 뷰 모드 변경 - const handleViewModeChange = async (mode: AlertViewMode) => { - setViewMode(mode); - await setStorage({ [ALERT_VIEW_MODE_KEY]: mode }); - }; - - // 카테고리 변경 const handleCategoryChange = async (category: AlertCategory | undefined) => { + const previousCategory = selectedCategory; setSelectedCategory(category); - await setStorage({ [ALERT_CATEGORY_KEY]: category || null }); + try { + await setStorage({ [ALERT_CATEGORY_KEY]: category ?? null }); + } catch (error) { + setSelectedCategory((current) => + current === category ? previousCategory : current, + ); + captureErrorLog("[Alerts] Failed to persist category:", error); + toast.error("공지 설정을 저장하지 못했습니다."); + } }; - // 초기화 전 로딩 표시 if (!isInitialized) { return ( -
-
+
+
); } return ( -
- {/* 헤더: 필터 */} -
- {/* 모든 공지 / 내 공지 탭 */} -
- -
- +
+
- - {/* 카테고리 필터 (모든 공지 모드일 때만 표시) */} -
+
{categories.map((category) => ( {
- {/* 콘텐츠 영역 */} -
- {/* 내 공지 모드 */} -
- {loggedIn && } -
- {/* 모든 공지 모드 */} -
+
+
{isLoading ? (
-
+
) : filteredAlerts.length > 0 ? ( filteredAlerts.map((alert) => ( @@ -207,12 +163,10 @@ const Alerts = () => { /> )) ) : ( -
-

- {hasSearchQuery && alerts.length > 0 - ? "검색 결과가 없습니다." - : "공지사항이 없습니다."} -

+
+ {hasSearchQuery && alerts.length > 0 + ? "검색 결과가 없습니다." + : "공지사항이 없습니다."}
)}
diff --git a/src/components/Tabs/Alerts/MyAlertsView.tsx b/src/components/Tabs/Alerts/MyAlertsView.tsx deleted file mode 100644 index 2ee3fe61..00000000 --- a/src/components/Tabs/Alerts/MyAlertsView.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; -import { - getSubscriptions, - getMySubscriptions, - getMyAlerts, - subscribeDepartment, - unsubscribeDepartment, -} from "@/apis"; -import type { Department, Subscription, GeneralAlert } from "@/types/api"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { X, Bell, Loader2, Search } from "lucide-react"; -import { toast } from "sonner"; -import AlertItem from "./AlertItem"; -import { captureErrorLog } from '@/utils/logger'; -import { matchesAlertQuery } from "./alertSearchUtils"; -import { sendAlertsSubscriptionChange } from '@/utils/analytics'; - -interface MyAlertsViewProps { - searchQuery: string; -} - -const MyAlertsView = ({ searchQuery }: MyAlertsViewProps) => { - const [departments, setDepartments] = useState([]); - const [mySubscriptions, setMySubscriptions] = useState([]); - const [myAlerts, setMyAlerts] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isSubscribing, setIsSubscribing] = useState(false); - const [isDepartmentsLoaded, setIsDepartmentsLoaded] = useState(false); - const [isLoadingDepartments, setIsLoadingDepartments] = useState(false); - const [open, setOpen] = useState(false); - - // 전체 학과 목록 로드 (드롭다운 열 때만) - const loadDepartments = useCallback(async () => { - if (isDepartmentsLoaded || isLoadingDepartments) return; - - setIsLoadingDepartments(true); - try { - const result = await getSubscriptions(); - if (result.success && Array.isArray(result.data)) { - setDepartments(result.data); - setIsDepartmentsLoaded(true); - } - } finally { - setIsLoadingDepartments(false); - } - }, [isDepartmentsLoaded, isLoadingDepartments]); - - // 내 구독 + 내 공지 로드 - const loadMyData = useCallback(async () => { - setIsLoading(true); - try { - const [subscriptionsResult, alertsResult] = await Promise.all([ - getMySubscriptions(), - getMyAlerts(), - ]); - - if (subscriptionsResult.success && Array.isArray(subscriptionsResult.data)) { - setMySubscriptions(subscriptionsResult.data); - } - - if (alertsResult.success && Array.isArray(alertsResult.data)) { - setMyAlerts(alertsResult.data); - } - } catch (error) { - captureErrorLog("Failed to load my data:", error); - toast.error("데이터를 불러오는데 실패했습니다."); - } finally { - setIsLoading(false); - } - }, []); - - // Popover 열릴 때 학과 목록 로드 - const handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen); - if (isOpen) { - loadDepartments(); - } - }; - - // 초기 로드 (내 구독 + 내 공지만) - useEffect(() => { - loadMyData(); - }, [loadMyData]); - - // 학과 구독 - const handleSubscribe = async (departmentId: number) => { - // 이미 구독 중인지 확인 - if (mySubscriptions.some((sub) => sub.department.id === departmentId)) { - toast.info("이미 구독 중인 학과입니다."); - return; - } - - setIsSubscribing(true); - try { - const result = await subscribeDepartment(departmentId); - if (result.success) { - const departmentName = departments.find( - (department) => department.id === departmentId, - )?.name; - if (departmentName) { - sendAlertsSubscriptionChange(departmentName, 'subscribe'); - } - toast.success("학과 구독 완료!"); - await loadMyData(); // 목록 새로고침 - } else { - toast.error(result.error?.message || "구독에 실패했습니다."); - } - } catch (error) { - captureErrorLog("Subscribe error:", error); - toast.error("구독 중 오류가 발생했습니다."); - } finally { - setIsSubscribing(false); - } - }; - - // 구독 취소 - const handleUnsubscribe = async (departmentId: number) => { - try { - const result = await unsubscribeDepartment(departmentId); - if (result.success) { - const departmentName = mySubscriptions.find( - (subscription) => subscription.department.id === departmentId, - )?.department.name; - if (departmentName) { - sendAlertsSubscriptionChange(departmentName, 'unsubscribe'); - } - toast.success("구독 취소 완료"); - await loadMyData(); // 목록 새로고침 - } else { - toast.error(result.error?.message || "구독 취소에 실패했습니다."); - } - } catch (error) { - captureErrorLog("Unsubscribe error:", error); - toast.error("구독 취소 중 오류가 발생했습니다."); - } - }; - - // 구독 가능한 학과만 필터링 (이미 구독 중인 학과 제외) - const availableDepartments = departments.filter( - (dept) => !mySubscriptions.some((sub) => sub.department.id === dept.id) - ); - - // 공지사항 시간순 정렬 (최신순) - const sortedAlerts = useMemo(() => { - return [...myAlerts].sort((a, b) => { - const dateA = new Date(a.publishedAt).getTime(); - const dateB = new Date(b.publishedAt).getTime(); - return dateB - dateA; - }); - }, [myAlerts]); - - const filteredAlerts = useMemo( - () => sortedAlerts.filter((alert) => matchesAlertQuery(alert, searchQuery)), - [searchQuery, sortedAlerts] - ); - - return ( -
- {/* 학과 구독 섹션 */} -
-
- - 학과 구독 -
- - {/* 학과 검색 Combobox */} - - - - - - - - - {isLoadingDepartments ? ( -
- -
- ) : ( - <> - 검색 결과가 없습니다 - - {availableDepartments.map((dept) => ( - { - handleSubscribe(dept.id); - setOpen(false); - }} - > - {dept.name} - - ))} - - - )} -
-
-
-
- - {/* 구독 중인 학과 뱃지 */} - {mySubscriptions.length > 0 && ( -
- {mySubscriptions.map((sub) => ( - handleUnsubscribe(sub.department.id)} - > - {sub.department.name} - - - ))} -
- )} -
- - {/* 구분선 */} -
- - {/* 내 공지사항 목록 */} -
- {isLoading ? ( -
- -
- ) : filteredAlerts.length > 0 ? ( -
- {filteredAlerts.map((alert) => ( - - ))} -
- ) : mySubscriptions.length === 0 ? ( -
-

구독한 학과가 없습니다.

-

위에서 학과를 선택해 구독해보세요!

-
- ) : sortedAlerts.length === 0 ? ( -
-

새로운 공지사항이 없습니다.

-
- ) : ( -
-

검색 결과가 없습니다.

-
- )} -
-
- ); -}; - -export default MyAlertsView; diff --git a/src/components/Tabs/Alerts/alertSearchUtils.ts b/src/components/Tabs/Alerts/alertSearchUtils.ts index 5b913d22..82264a23 100644 --- a/src/components/Tabs/Alerts/alertSearchUtils.ts +++ b/src/components/Tabs/Alerts/alertSearchUtils.ts @@ -16,10 +16,8 @@ export const matchesAlertQuery = (alert: Alert, query: string) => { return true; } - const sourceName = - "department" in alert ? alert.department.name : alert.category; const searchableText = normalizeSearchText( - [alert.title, alert.content, sourceName].filter(Boolean).join(" ") + [alert.title, alert.content, alert.category].filter(Boolean).join(" ") ); return queryTokens.every((token) => searchableText.includes(token)); diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx deleted file mode 100644 index 61c9a709..00000000 --- a/src/components/ui/avatar.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Avatar component - shadcn/ui pattern - * Displays user profile pictures with fallback support - */ - -import * as React from "react"; -import * as AvatarPrimitive from "@radix-ui/react-avatar"; -import { cn } from "@/lib/utils"; - -const Avatar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -Avatar.displayName = AvatarPrimitive.Root.displayName; - -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AvatarImage.displayName = AvatarPrimitive.Image.displayName; - -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; - -export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/constants/template.ts b/src/constants/template.ts index 058002e1..1db00059 100644 --- a/src/constants/template.ts +++ b/src/constants/template.ts @@ -2,7 +2,7 @@ * The rules that define a template: what it may contain, and how an unsaved * one is spelled. * - * Kept in a leaf module so the storage layer, the share codec and the renderer + * Kept in a leaf module so the storage layer, cloud codec and renderer * validate against the same numbers without pulling React or icon rendering * along with them. */ @@ -21,8 +21,8 @@ export const MAX_TEMPLATE_NAME_LENGTH = 80; export const MAX_SITE_URL_LENGTH = 2_048; /** - * Icon images that may travel inside a shared template or be registered as an - * asset. SVG is excluded on purpose: it can carry script. + * Icon images that may be imported into local storage. SVG is excluded on + * purpose because it can carry script. */ export const PORTABLE_ICON_PATTERN = /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/u; diff --git a/src/constants/templateIcons.ts b/src/constants/templateIcons.ts index be26a694..cb1d5071 100644 --- a/src/constants/templateIcons.ts +++ b/src/constants/templateIcons.ts @@ -8,9 +8,8 @@ import { convertLucideIconToDataUri } from "@/utils/iconDataUri"; let bundledIcons: Icon[] | undefined; /** - * Fallback icon for links whose original image cannot travel — a remote URL - * that we refuse to re-request from a shared template, or a bundled icon the - * receiving version no longer has. + * Fallback icon for remote image URLs that are not portable or bundled icons + * that a newer or older client no longer recognizes. * * It is a bundled icon rather than a synthetic placeholder so the editor can * resolve it like any other: an item pointing at an icon that no list holds diff --git a/src/contexts/EditorContext.tsx b/src/contexts/EditorContext.tsx index c96a36a1..e6faf05a 100644 --- a/src/contexts/EditorContext.tsx +++ b/src/contexts/EditorContext.tsx @@ -11,7 +11,7 @@ import { createDefaultLinkList } from '@/constants/LinkList'; import { BULLETIN_FALLBACK } from '@/constants/bulletin'; import { getBundledTemplateIcons } from '@/constants/templateIcons'; import { convertLinkListToTemplateItems, calculateTemplateHeight } from '@/utils/template'; -import { getLocalTemplate } from '@/utils/templateStorage'; +import { getLocalTemplate } from '@/storage/templates/repository'; import { debugLog, captureErrorLog } from '@/utils/logger'; import { EditorContext } from './EditorContextObject'; import { GRID_COLUMNS, UNSAVED_TEMPLATE_ID } from '@/constants/template'; diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts new file mode 100644 index 00000000..4855ece6 --- /dev/null +++ b/src/hooks/useAccountSync.ts @@ -0,0 +1,80 @@ +import { useEffect } from "react"; +import { isLoggedIn } from "@/utils/oauth"; +import { getGoogleAccountId } from "@/apis/supabase/account"; +import { isSupabaseConfigured } from "@/apis/supabase/client"; +import { + activateSyncAccount, + getActiveSyncAccountId, + SyncAccountMismatchError, +} from "@/storage/account/syncRepository"; +import { syncAccount } from "@/utils/accountSync"; +import { isExpectedNetworkFailure } from "@/utils/networkFailure"; +import { captureErrorLog } from "@/utils/logger"; +import { recordBreadcrumb } from "@/monitoring"; +import { UserFacingError } from "@/errors/userFacingError"; + +export function useAccountSync(): void { + useEffect(() => { + if (!isSupabaseConfigured()) return; + let disposed = false; + + const run = async () => { + const boundAccountId = await getActiveSyncAccountId(); + if (!boundAccountId || !(await isLoggedIn())) return; + const result = await syncAccount(); + if (!disposed && result.failed > 0) { + recordBreadcrumb( + "account.sync", + "background sync completed with deferred operations", + { failed: result.failed, conflicts: result.conflicts }, + "warning", + ); + } + }; + + const initialize = async () => { + const accountId = await getGoogleAccountId(); + if (!accountId) return; + await activateSyncAccount(accountId); + await run(); + }; + + const report = (error: unknown) => { + if ( + error instanceof SyncAccountMismatchError || + error instanceof UserFacingError || + isExpectedNetworkFailure(error) + ) { + recordBreadcrumb( + "account.sync", + "automatic sync unavailable", + { + reason: + error instanceof SyncAccountMismatchError + ? "account_mismatch" + : error instanceof UserFacingError + ? error.code + : "network", + }, + "warning", + ); + return; + } + captureErrorLog("[Account sync] Automatic sync failed", error); + }; + + const trigger = () => { + void run().catch(report); + }; + void initialize().catch(report); + window.addEventListener("auth:login", trigger); + window.addEventListener("online", trigger); + window.addEventListener("linku:templates-changed", trigger); + return () => { + disposed = true; + window.removeEventListener("auth:login", trigger); + window.removeEventListener("online", trigger); + window.removeEventListener("linku:templates-changed", trigger); + }; + }, []); +} diff --git a/src/hooks/useSelectedTemplate.ts b/src/hooks/useSelectedTemplate.ts index 1c3fd303..52068c05 100644 --- a/src/hooks/useSelectedTemplate.ts +++ b/src/hooks/useSelectedTemplate.ts @@ -16,7 +16,7 @@ import { type LinkListElement, } from "@/constants/LinkList"; import type { BulletinInfo } from "@/constants/bulletin"; -import { getLocalTemplate } from "@/utils/templateStorage"; +import { getLocalTemplate } from "@/storage/templates/repository"; import { debugLog, captureErrorLog } from '@/utils/logger'; import { UNSAVED_TEMPLATE_ID } from '@/constants/template'; diff --git a/src/pages/GalleryPage.tsx b/src/pages/GalleryPage.tsx index 81caacde..fae2e5b8 100644 --- a/src/pages/GalleryPage.tsx +++ b/src/pages/GalleryPage.tsx @@ -1,57 +1,282 @@ -import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router'; -import { ArrowLeft, Download, Sparkles } from 'lucide-react'; -import { TemplateCard } from '@/components/Editor/TemplatePreview/TemplateCard'; -import { Button } from '@/components/ui/button'; -import { useToast } from '@/components/ui/use-toast'; -import { createBundledDefaultTemplate } from '@/utils/defaultTemplate'; -import { importTemplateCopy } from '@/utils/templateStorage'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { FormEvent } from "react"; +import { useNavigate } from "react-router"; import { - resolveLatestBulletin, - subscribeLatestBulletin, -} from '@/apis/external/bulletin'; -import { captureErrorLog } from '@/utils/logger'; + ArrowLeft, + Copy, + Download, + Heart, + Loader2, + Search, + Sparkles, +} from "lucide-react"; +import { TemplateCard } from "@/components/Editor/TemplatePreview/TemplateCard"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/components/ui/use-toast"; +import { createBundledDefaultTemplate } from "@/utils/defaultTemplate"; +import { importTemplateCopy } from "@/storage/templates/repository"; +import { + browsePublications, + clonePublication, + createPublicationPreview, + setPublicationLiked, +} from "@/apis/supabase/community"; +import { + SupabaseConfigurationError, + isSupabaseConfigured, +} from "@/apis/supabase/client"; +import type { + PublicationSort, + TemplatePublication, +} from "@/types/account"; +import type { Template } from "@/types/api"; +import { isLoggedIn, startGoogleLogin } from "@/utils/oauth"; +import { captureErrorLog } from "@/utils/logger"; +import { isExpectedNetworkFailure } from "@/utils/networkFailure"; +import { recordBreadcrumb } from "@/monitoring"; +import { UserFacingError } from "@/errors/userFacingError"; + +const PAGE_SIZE = 12; +const MAX_SEARCH_LENGTH = 80; + +function reportCommunityFailure(message: string, error: unknown) { + if ( + error instanceof UserFacingError || + error instanceof SupabaseConfigurationError || + isExpectedNetworkFailure(error) + ) { + recordBreadcrumb( + "community.gallery", + message, + { + reason: + error instanceof UserFacingError + ? error.code + : error instanceof SupabaseConfigurationError + ? "not_configured" + : "network", + }, + "warning", + ); + return; + } + captureErrorLog(message, error); +} + +function PublicationCard({ + publication, + preview, + busy, + onClone, + onLike, +}: { + publication: TemplatePublication; + preview: Template; + busy: boolean; + onClone: () => void; + onLike: () => void; +}) { + return ( +
+ +
+
+ + {publication.authorNickname} + +
+ + + {publication.likeCount} + + + + {publication.cloneCount} + +
+
+
+ + +
+
+
+ ); +} export const GalleryPage = () => { const navigate = useNavigate(); const { toast } = useToast(); - const [importing, setImporting] = useState(false); - const [template, setTemplate] = useState(createBundledDefaultTemplate); + const [queryInput, setQueryInput] = useState(""); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("latest"); + const [publications, setPublications] = useState([]); + const [previews, setPreviews] = useState>({}); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [communityUnavailable, setCommunityUnavailable] = useState( + !isSupabaseConfigured(), + ); + const [busyTemplateId, setBusyTemplateId] = useState(null); + const bundledTemplate = useMemo(() => createBundledDefaultTemplate(), []); + const loadRequestIdRef = useRef(0); + + const load = useCallback( + async (offset = 0) => { + const requestId = ++loadRequestIdRef.current; + if (!isSupabaseConfigured()) { + setCommunityUnavailable(true); + setLoading(false); + return; + } + if (offset === 0) { + setLoading(true); + } else { + setLoadingMore(true); + } + try { + const next = await browsePublications({ + query, + sort, + offset, + limit: PAGE_SIZE, + }); + const nextPreviews = await Promise.all( + next.map(async (publication) => [ + publication.templateId, + await createPublicationPreview(publication), + ] as const), + ); + if (requestId !== loadRequestIdRef.current) return; + setPublications((current) => (offset === 0 ? next : [...current, ...next])); + setPreviews((current) => ({ + ...(offset === 0 ? {} : current), + ...Object.fromEntries(nextPreviews), + })); + setHasMore(next.length === PAGE_SIZE); + setCommunityUnavailable(false); + } catch (error) { + if (requestId !== loadRequestIdRef.current) return; + if (offset === 0) { + setCommunityUnavailable(true); + setPublications([]); + setPreviews({}); + } else { + toast({ + title: "더 불러오지 못했습니다", + description: "잠시 후 다시 시도해 주세요.", + variant: "destructive", + }); + } + reportCommunityFailure("community gallery unavailable", error); + } finally { + if (requestId === loadRequestIdRef.current) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [query, sort, toast], + ); useEffect(() => { - const applyBulletin = (bulletin: Parameters[0]) => { - setTemplate(createBundledDefaultTemplate(bulletin)); + void load(); + return () => { + loadRequestIdRef.current += 1; }; - const unsubscribe = subscribeLatestBulletin(applyBulletin); - void resolveLatestBulletin().then(applyBulletin); - return unsubscribe; - }, []); + }, [load]); + + const handleSearch = (event: FormEvent) => { + event.preventDefault(); + setQuery(queryInput.trim()); + }; - const handleImport = async () => { - setImporting(true); + const handleClone = async (publication: TemplatePublication) => { + setBusyTemplateId(publication.templateId); try { - const stored = await importTemplateCopy(template); + const templateId = await clonePublication(publication); toast({ - title: '템플릿 추가 완료', - description: '서버 연결 없이 이 기기에 저장했습니다.', + title: "템플릿 복제 완료", + description: "이 기기에 독립적인 복사본으로 저장했습니다.", }); - navigate(`/editor/${stored.template.templateId}`); + navigate(`/editor/${templateId}`); + } catch (error) { + reportCommunityFailure("[Gallery] Failed to clone publication", error); + toast({ + title: "복제 실패", + description: error instanceof Error ? error.message : "템플릿을 저장하지 못했습니다.", + variant: "destructive", + }); + } finally { + setBusyTemplateId(null); + } + }; + + const handleLike = async (publication: TemplatePublication) => { + setBusyTemplateId(publication.templateId); + try { + if (!(await isLoggedIn())) { + const login = await startGoogleLogin(); + if (!login.success) { + toast({ title: "Google 로그인 필요", description: login.error }); + return; + } + } + const liked = !publication.isLiked; + const likeCount = await setPublicationLiked(publication.templateId, liked); + setPublications((current) => + current.map((item) => + item.templateId === publication.templateId + ? { ...item, isLiked: liked, likeCount } + : item, + ), + ); } catch (error) { - captureErrorLog('Failed to import bundled template', error); + reportCommunityFailure("[Gallery] Failed to update like", error); toast({ - title: '가져오기 실패', - description: '브라우저 저장소에 템플릿을 추가하지 못했습니다.', - variant: 'destructive', + title: "좋아요 저장 실패", + description: "잠시 후 다시 시도해 주세요.", + variant: "destructive", }); } finally { - setImporting(false); + setBusyTemplateId(null); + } + }; + + const handleBundledImport = async () => { + try { + const stored = await importTemplateCopy(bundledTemplate); + navigate(`/editor/${stored.template.templateId}`); + } catch (error) { + captureErrorLog("[Gallery] Failed to import bundled template", error); + toast({ + title: "가져오기 실패", + description: "브라우저 저장소에 템플릿을 추가하지 못했습니다.", + variant: "destructive", + }); } }; return ( -
-
-
@@ -60,20 +285,100 @@ export const GalleryPage = () => {

템플릿 둘러보기

- 커뮤니티가 열리기 전에는 검증된 템플릿을 확장 프로그램에 함께 제공합니다. + 게시된 템플릿을 익명으로 둘러보고 내 복사본으로 저장하세요.

-
+ -
- - -
-
+
+
+ setQueryInput(event.target.value)} + placeholder="템플릿 이름이나 작성자 검색" + aria-label="템플릿 검색" + /> + +
+
+ {([ + ["latest", "최신순"], + ["likes", "좋아요순"], + ["clones", "복제순"], + ] as const).map(([value, label]) => ( + + ))} +
+
+ + {communityUnavailable && publications.length === 0 && ( +
+ 커뮤니티에 연결할 수 없어 함께 제공되는 기본 템플릿을 표시합니다. +
+ )} + + {loading ? ( +
+ +
+ ) : publications.length > 0 ? ( +
+ {publications.map((publication) => { + const preview = previews[publication.templateId]; + return preview ? ( + void handleClone(publication)} + onLike={() => void handleLike(publication)} + /> + ) : null; + })} +
+ ) : !communityUnavailable ? ( +
+ {query ? "검색 결과가 없습니다." : "아직 게시된 템플릿이 없습니다."} +
+ ) : null} + + {hasMore && ( +
+ +
+ )} + + {communityUnavailable && publications.length === 0 && ( +
+ + +
+ )} + ); }; diff --git a/src/pages/TemplateListPage.tsx b/src/pages/TemplateListPage.tsx index 81e7ad29..959ee194 100644 --- a/src/pages/TemplateListPage.tsx +++ b/src/pages/TemplateListPage.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { AlertTriangle, + Cloud, + CloudOff, DatabaseBackup, FileText, - FileUp, LayoutTemplate, Plus, + RefreshCw, Sparkles, } from 'lucide-react'; import { TemplateCard } from '@/components/Editor/TemplatePreview/TemplateCard'; @@ -25,21 +27,13 @@ import { createTemplateBackup, countQuarantinedRecords, deleteLocalTemplate, - importSharedTemplate, isTemplateBackupValidationError, - getLocalTemplate, listQuarantinedRecords, listLocalTemplates, MAX_TEMPLATE_BACKUP_BYTES, + PublishedTemplateDeleteError, restoreTemplateBackup, -} from '@/utils/templateStorage'; -import { - createTemplateShareUrl, - downloadTemplatePayload, - isTemplateShareValidationError, - MAX_SHARE_FILE_BYTES, - validateTemplateSharePayload, -} from '@/utils/templateShare'; +} from '@/storage/templates/repository'; import { createBundledDefaultTemplate } from '@/utils/defaultTemplate'; import { resolveLatestBulletin, @@ -50,12 +44,34 @@ import { downloadJson } from '@/utils/download'; import { captureErrorLog, warnLog } from '@/utils/logger'; import { UserFacingError } from '@/errors/userFacingError'; import { recordBreadcrumb } from '@/monitoring'; +import { + isPublicationOutdated, + publishLocalTemplate, + refreshPublicationMetadata, + unpublishLocalTemplate, +} from '@/apis/supabase/community'; +import { SupabaseConfigurationError } from '@/apis/supabase/client'; +import { + getTemplateAccountStates, +} from '@/storage/account/syncRepository'; +import type { AccountSyncStatus } from '@/types/account'; +import { syncAccount } from '@/utils/accountSync'; +import { getAccountSyncFeedback } from '@/utils/accountSyncResult'; +import { isExpectedNetworkFailure } from '@/utils/networkFailure'; +import { isLoggedIn, startGoogleLogin } from '@/utils/oauth'; import { sendTemplateApply, sendTemplateCreateStart, sendTemplateDelete, } from '@/utils/analytics'; +interface TemplateListItem extends TemplateSummary { + syncId?: string; + accountSyncStatus: AccountSyncStatus; + published: boolean; + publicationOutdated: boolean; +} + function toSummary(template: Template): TemplateSummary { return { templateId: template.templateId, @@ -73,12 +89,24 @@ function toSummary(template: Template): TemplateSummary { function reportTemplateOperationFailure(message: string, error: unknown) { if ( isTemplateBackupValidationError(error) || - isTemplateShareValidationError(error) + error instanceof UserFacingError || + error instanceof PublishedTemplateDeleteError || + error instanceof SupabaseConfigurationError || + isExpectedNetworkFailure(error) ) { recordBreadcrumb( - 'template.validation', + 'template.operation', message, - { validation_code: error.code }, + { + reason: + error instanceof UserFacingError + ? error.code + : error instanceof SupabaseConfigurationError + ? 'not_configured' + : isExpectedNetworkFailure(error) + ? 'network' + : 'local_state', + }, 'warning', ); warnLog(message, error); @@ -95,11 +123,12 @@ export const TemplateListPage = () => { const [defaultTemplate, setDefaultTemplate] = useState( createBundledDefaultTemplate, ); - const [templates, setTemplates] = useState([]); + const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState<'owned' | 'cloned'>('owned'); const [actionLoading, setActionLoading] = useState(null); - const importInputRef = useRef(null); + const [syncing, setSyncing] = useState(false); + const [accountConnected, setAccountConnected] = useState(false); const restoreInputRef = useRef(null); const loadRequestIdRef = useRef(0); const [quarantinedCount, setQuarantinedCount] = useState(0); @@ -119,11 +148,46 @@ export const TemplateListPage = () => { try { const storedTemplates = await listLocalTemplates(); const nextQuarantinedCount = await countQuarantinedRecords(); + let connected = false; + try { + connected = await isLoggedIn(); + if (connected) { + await refreshPublicationMetadata(); + } + } catch (error) { + reportTemplateOperationFailure( + 'Failed to refresh publication metadata', + error, + ); + } + const accountStates = await getTemplateAccountStates( + storedTemplates.map((stored) => stored.template.id), + ); + const nextTemplates = await Promise.all( + storedTemplates.map(async (stored): Promise => { + const syncId = stored.template.id; + const accountState = accountStates.get(syncId) ?? { + status: 'local' as const, + isPublished: false, + }; + return { + ...toSummary(stored.template), + syncId, + accountSyncStatus: accountState.status, + published: accountState.isPublished, + publicationOutdated: + accountState.isPublished && + (await isPublicationOutdated( + stored, + accountState.publishedContentHash, + )), + }; + }), + ); if (requestId !== loadRequestIdRef.current) return; - setTemplates( - storedTemplates.map((stored) => toSummary(stored.template)), - ); + setTemplates(nextTemplates); + setAccountConnected(connected); // Reading is what moves an unreadable record into quarantine, so the // count is refreshed here rather than on mount. setQuarantinedCount(nextQuarantinedCount); @@ -156,7 +220,15 @@ export const TemplateListPage = () => { }, [loadTemplates]); const ownedTemplates = useMemo( - () => [toSummary(defaultTemplate), ...templates.filter((template) => !template.cloned)], + (): TemplateListItem[] => [ + { + ...toSummary(defaultTemplate), + accountSyncStatus: 'local', + published: false, + publicationOutdated: false, + }, + ...templates.filter((template) => !template.cloned), + ], [defaultTemplate, templates], ); const clonedTemplates = useMemo( @@ -203,7 +275,14 @@ export const TemplateListPage = () => { }); }; - const handleDeleteTemplate = async (template: TemplateSummary) => { + const handleDeleteTemplate = async (template: TemplateListItem) => { + if (template.published) { + toast({ + title: '게시 중인 템플릿', + description: '게시를 내린 뒤 삭제해 주세요.', + }); + return; + } if (!confirm(`“${template.name}” 템플릿을 삭제하시겠습니까?`)) return; try { if ( @@ -233,45 +312,73 @@ export const TemplateListPage = () => { title: '삭제 완료', description: '이 기기의 저장소에서 삭제했습니다.', }); + window.dispatchEvent(new Event('linku:templates-changed')); } catch (error) { - captureErrorLog('Failed to delete local template', error); + reportTemplateOperationFailure('Failed to delete local template', error); toast({ title: '삭제 실패', - description: '이 기기의 저장소에서 템플릿을 삭제하지 못했습니다.', + description: + error instanceof Error + ? error.message + : '이 기기의 저장소에서 템플릿을 삭제하지 못했습니다.', variant: 'destructive', }); } }; - const handleShareTemplate = async (templateId: number) => { - setActionLoading(templateId); + const ensureAccount = async (): Promise => { + if (await isLoggedIn()) return true; + const result = await startGoogleLogin(); + if (!result.success) { + toast({ title: 'Google 로그인 필요', description: result.error }); + return false; + } + setAccountConnected(true); + return true; + }; + + const handleSync = async () => { + setSyncing(true); try { - const stored = - templateId === UNSAVED_TEMPLATE_ID - ? { template: defaultTemplate } - : await getLocalTemplate(templateId); - if (!stored) throw new Error('이 기기에서 템플릿을 찾을 수 없습니다.'); + if (!(await ensureAccount())) return; + const result = await syncAccount(); + const feedback = getAccountSyncFeedback(result); + toast({ + title: feedback.title, + description: feedback.description, + variant: feedback.destructive ? 'destructive' : 'default', + }); + await loadTemplates(); + } catch (error) { + reportTemplateOperationFailure('Failed to sync templates', error); + toast({ + title: '동기화 실패', + description: + error instanceof Error ? error.message : '잠시 후 다시 시도해 주세요.', + variant: 'destructive', + }); + } finally { + setSyncing(false); + } + }; - const share = await createTemplateShareUrl(stored.template); - if (share.mode === 'url') { - await navigator.clipboard.writeText(share.url); - toast({ - title: '공유 링크 복사 완료', - description: '템플릿 데이터는 링크의 fragment에만 들어 있습니다.', - }); - } else { - downloadTemplatePayload(share.payload); - toast({ - title: '공유 파일 저장 완료', - description: '링크에 담기 큰 템플릿이라 파일로 저장했습니다.', - }); - } + const handlePublish = async (template: TemplateListItem) => { + if (!template.syncId) return; + setActionLoading(template.templateId); + try { + if (!(await ensureAccount())) return; + await publishLocalTemplate(template.syncId); + await loadTemplates(); + toast({ + title: template.published ? '게시물 업데이트 완료' : '템플릿 게시 완료', + description: '커뮤니티에 현재 저장본을 공개했습니다.', + }); } catch (error) { - captureErrorLog('Failed to share template', error); + reportTemplateOperationFailure('Failed to publish template', error); toast({ - title: '공유 실패', + title: '게시 실패', description: - error instanceof Error ? error.message : '공유 데이터를 만들지 못했습니다.', + error instanceof Error ? error.message : '잠시 후 다시 시도해 주세요.', variant: 'destructive', }); } finally { @@ -279,48 +386,25 @@ export const TemplateListPage = () => { } }; - const handleImportFile = async (file: File | undefined) => { - if (!file) return; + const handleUnpublish = async (template: TemplateListItem) => { + if (!template.syncId || !confirm(`“${template.name}” 게시를 내리시겠습니까?`)) { + return; + } + setActionLoading(template.templateId); try { - let value: unknown; - try { - if (file.size > MAX_SHARE_FILE_BYTES) { - throw new Error('템플릿 가져오기 파일은 256KB 이하여야 합니다.'); - } - value = JSON.parse(await file.text()) as unknown; - validateTemplateSharePayload(value); - } catch (error) { - toast({ - title: '가져오기 실패', - description: - error instanceof Error ? error.message : '템플릿 파일을 읽지 못했습니다.', - variant: 'destructive', - }); - return; - } - - try { - const imported = await importSharedTemplate(value); - await loadTemplates(); - setActiveTab('cloned'); - toast({ - title: '템플릿 가져오기 완료', - description: `“${imported.template.name}”을 이 기기에 저장했습니다.`, - }); - } catch (error) { - reportTemplateOperationFailure( - 'Failed to store an imported template', - error, - ); - toast({ - title: '가져오기 실패', - description: - error instanceof Error ? error.message : '템플릿을 저장하지 못했습니다.', - variant: 'destructive', - }); - } + await unpublishLocalTemplate(template.syncId); + await loadTemplates(); + toast({ title: '게시를 내렸습니다' }); + } catch (error) { + reportTemplateOperationFailure('Failed to unpublish template', error); + toast({ + title: '게시 내리기 실패', + description: + error instanceof Error ? error.message : '잠시 후 다시 시도해 주세요.', + variant: 'destructive', + }); } finally { - if (importInputRef.current) importInputRef.current.value = ''; + setActionLoading(null); } }; @@ -368,6 +452,7 @@ export const TemplateListPage = () => { parsedBackup, ); await loadTemplates(); + window.dispatchEvent(new Event('linku:templates-changed')); const hasRestoreWarnings = result.skipped > 0 || result.failedAssets > 0; toast({ @@ -429,56 +514,116 @@ export const TemplateListPage = () => { return (
- {visibleTemplates.map((template) => ( - navigate(`/editor/${template.templateId}`) - } - isSelected={ - selectedTemplateId === null - ? template.templateId === UNSAVED_TEMPLATE_ID - : selectedTemplateId === template.templateId - } - onApply={(event) => { - event.stopPropagation(); - void handleApplyTemplate(template); - }} - onDelete={(event) => { - event.stopPropagation(); - void handleDeleteTemplate(template); - }} - onShare={(event) => { - event.stopPropagation(); - void handleShareTemplate(template.templateId); - }} - showDelete={template.templateId !== UNSAVED_TEMPLATE_ID} - isActionLoading={actionLoading === template.templateId} - /> - ))} + {visibleTemplates.map((template) => { + const isStored = template.templateId !== UNSAVED_TEMPLATE_ID; + const isBusy = actionLoading === template.templateId; + const syncLabel = { + error: '동기화 지연', + local: accountConnected ? '동기화 전' : '이 기기에 저장', + pending: '동기화 대기', + synced: '동기화됨', + }[template.accountSyncStatus]; + + return ( +
+ navigate(`/editor/${template.templateId}`) + : undefined + } + isSelected={ + selectedTemplateId === null + ? !isStored + : selectedTemplateId === template.templateId + } + onApply={(event) => { + event.stopPropagation(); + void handleApplyTemplate(template); + }} + onDelete={(event) => { + event.stopPropagation(); + void handleDeleteTemplate(template); + }} + showDelete={isStored} + isActionLoading={isBusy} + /> + + {isStored && ( +
+
+ {template.accountSyncStatus === 'synced' ? ( + + ) : ( + + )} + {syncLabel} + {template.published && ( + + {template.publicationOutdated ? '업데이트 필요' : '게시됨'} + + )} +
+ +
+ {(!template.published || template.publicationOutdated) && ( + + )} + {template.published && ( + + )} +
+
+ )} +
+ ); + })}
); }; return (
-
+

내 템플릿

- 로그인이나 서버 연결 없이 이 기기에 바로 저장합니다. + 먼저 이 기기에 저장하고, 로그인하면 여러 기기와 동기화합니다.

-
- + - + @@ -487,9 +632,6 @@ export const TemplateListPage = () => { 빈 템플릿에서 시작 - importInputRef.current?.click()}> - 파일에서 가져오기 - void handleDownloadBackup()}> 전체 백업 내려받기 @@ -498,13 +640,6 @@ export const TemplateListPage = () => { - void handleImportFile(event.target.files?.[0])} - /> { + const database = await getLinkuDb(); + const entries = await database.getAllFromIndex("outbox", "by-queued-at"); + return entries.map((entry) => ({ + ...entry, + generation: + entry.generation ?? + `${entry.key}:${entry.queuedAt}:${entry.operation}`, + resource: entry.resource ?? "template", + })); +} + +export async function isSyncOutboxEntryCurrent( + expected: SyncOutboxEntry, +): Promise { + const database = await getLinkuDb(); + const current = await database.get("outbox", expected.key); + return current ? isCurrentOperation(current, expected) : false; +} + +function isCurrentOperation( + current: SyncOutboxEntry, + expected: SyncOutboxEntry, +): boolean { + const currentGeneration = + current.generation ?? + `${current.key}:${current.queuedAt}:${current.operation}`; + return currentGeneration === expected.generation; +} + +export async function removeSyncOutboxEntry( + expected: SyncOutboxEntry, +): Promise { + const database = await getLinkuDb(); + const transaction = database.transaction("outbox", "readwrite"); + const store = transaction.objectStore("outbox"); + const current = await store.get(expected.key); + if (current && isCurrentOperation(current, expected)) { + await store.delete(expected.key); + } + await transaction.done; +} + +export async function markSyncAttempt( + expected: SyncOutboxEntry, + 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(expected.key); + if (current && isCurrentOperation(current, expected)) { + await outbox.put({ ...current, attempts: current.attempts + 1 }); + const metadataStore = transaction.objectStore("syncMeta"); + const metadata = await metadataStore.get(metadataKey); + await metadataStore.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 interface PublicationMetadataState { + templateId: string; + revision: number; + contentHash?: string; + isPublished: boolean; +} + +export async function replacePublicationMetadata( + accountId: string, + publications: PublicationMetadataState[], +): Promise { + const database = await getLinkuDb(); + const transaction = database.transaction("syncMeta", "readwrite"); + const store = transaction.objectStore("syncMeta"); + const prefix = `${accountId}:template:`; + const metadataEntries = await store.getAll(); + const metadataByKey = new Map( + metadataEntries.map((metadata) => [metadata.key, metadata]), + ); + const publicationIds = new Set( + publications.map((publication) => publication.templateId), + ); + + for (const publication of publications) { + const key = `${prefix}${publication.templateId}`; + await store.put({ + ...metadataByKey.get(key), + key, + publicationRevision: publication.revision, + publishedContentHash: publication.contentHash, + isPublished: publication.isPublished, + }); + } + + for (const metadata of metadataEntries) { + if (!metadata.key.startsWith(prefix)) continue; + const templateId = metadata.key.slice(prefix.length); + if (publicationIds.has(templateId)) continue; + if ( + metadata.publicationRevision === undefined && + metadata.publishedContentHash === undefined && + metadata.isPublished === undefined + ) { + continue; + } + await store.put({ + ...metadata, + publicationRevision: undefined, + publishedContentHash: undefined, + isPublished: false, + }); + } + await transaction.done; +} + +export async function completeSyncOperation( + expected: SyncOutboxEntry, + metadata: SyncMetadata, +): Promise { + const database = await getLinkuDb(); + const transaction = database.transaction(["outbox", "syncMeta"], "readwrite"); + const outbox = transaction.objectStore("outbox"); + const current = await outbox.get(expected.key); + if (current && isCurrentOperation(current, expected)) { + await outbox.delete(expected.key); + } + await transaction.objectStore("syncMeta").put(metadata); + await transaction.done; +} + +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( + ["assets", "templates", "settings", "outbox"], + "readwrite", + ); + const [assets, templates] = await Promise.all([ + transaction.objectStore("assets").getAll(), + transaction.objectStore("templates").getAll(), + ]); + const outbox = transaction.objectStore("outbox"); + await outbox.clear(); + const queuedAt = Date.now(); + for (const stored of templates) { + await outbox.put( + createSyncOutboxEntry("template", stored.template.id, "put", queuedAt), + ); + } + for (const asset of assets) { + await outbox.put(createSyncOutboxEntry("asset", asset.id, "put", queuedAt)); + } + await transaction.objectStore("settings").put({ + key: ACTIVE_ACCOUNT_KEY, + value: accountId, + }); + await transaction.done; + return true; +} + +export async function clearCloudSyncState(): Promise { + const database = await getLinkuDb(); + const transaction = database.transaction(["outbox", "syncMeta"], "readwrite"); + await Promise.all([ + transaction.objectStore("outbox").clear(), + transaction.objectStore("syncMeta").clear(), + ]); + await transaction.done; +} + +export async function resetSyncConnection(): 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 getActiveSyncAccountId(): Promise { + const database = await getLinkuDb(); + const account = await database.get("settings", ACTIVE_ACCOUNT_KEY); + return typeof account?.value === "string" ? account.value : null; +} + +export interface TemplateAccountState { + status: AccountSyncStatus; + isPublished: boolean; + publishedContentHash?: string; +} + +export async function getTemplateAccountStates( + resourceIds: string[], +): Promise> { + if (resourceIds.length === 0) return new Map(); + const database = await getLinkuDb(); + const accountId = await getActiveSyncAccountId(); + if (!accountId) { + return new Map( + resourceIds.map((resourceId) => [ + resourceId, + { status: "local", isPublished: false }, + ]), + ); + } + + const [outboxEntries, metadataEntries] = await Promise.all([ + database.getAll("outbox"), + database.getAll("syncMeta"), + ]); + const outbox = new Map(outboxEntries.map((entry) => [entry.key, entry])); + const metadata = new Map(metadataEntries.map((entry) => [entry.key, entry])); + + return new Map( + resourceIds.map((resourceId) => { + const pending = outbox.get(`template:${resourceId}`); + const current = metadata.get( + syncMetadataKey(accountId, "template", resourceId), + ); + const status: AccountSyncStatus = + pending && pending.attempts > 0 && current?.lastError + ? "error" + : pending + ? "pending" + : current?.revision + ? "synced" + : "local"; + return [ + resourceId, + { + status, + isPublished: current?.isPublished === true, + publishedContentHash: current?.publishedContentHash, + }, + ]; + }), + ); +} + +export async function isTemplatePublished(resourceId: string): Promise { + const accountId = await getActiveSyncAccountId(); + if (!accountId) return false; + const metadata = await getSyncMetadata( + syncMetadataKey(accountId, "template", resourceId), + ); + return metadata?.isPublished === true; +} diff --git a/src/storage/linkuDb.ts b/src/storage/indexedDb/linkuDatabase.ts similarity index 70% rename from src/storage/linkuDb.ts rename to src/storage/indexedDb/linkuDatabase.ts index bc7f7b17..58c1c480 100644 --- a/src/storage/linkuDb.ts +++ b/src/storage/indexedDb/linkuDatabase.ts @@ -23,6 +23,35 @@ export interface StoredAsset { createdAt: number; } +export type SyncOperation = "put" | "delete"; +export type SyncResource = "asset" | "template"; + +export interface SyncOutboxEntry { + key: string; + generation: string; + resource: SyncResource; + resourceId: string; + operation: SyncOperation; + queuedAt: number; + attempts: number; +} + +export interface SyncMetadata { + key: string; + revision?: number; + contentHash?: string; + publicationRevision?: number; + publishedContentHash?: string; + isPublished?: boolean; + lastSyncedAt?: number; + lastError?: string; +} + +export interface StoredSetting { + key: string; + value: unknown; +} + /** Where an active template record lives. */ export type RecordLocation = { store: "templates"; key: number }; @@ -81,16 +110,27 @@ export interface LinkuDatabase extends DBSchema { key: string; value: QuarantinedRecord; }; + settings: { + key: string; + value: StoredSetting; + }; + outbox: { + key: string; + value: SyncOutboxEntry; + indexes: { "by-queued-at": number }; + }; + syncMeta: { + key: string; + value: SyncMetadata; + }; } export type LinkuDb = IDBPDatabase; const DATABASE_NAME = "linku"; -// Pre-release unpacked/test profiles may already be at version 2 or 3 with a -// partial store set. Version 4 forces one more additive compatibility upgrade: -// it fills every missing stateless store while retaining existing stores and -// their data. -export const LINKU_DATABASE_VERSION = 4; +// Version 4 is the shipped local-only schema. Version 5 adds account sync +// stores without rewriting or deleting any local record. +export const LINKU_DATABASE_VERSION = 5; function openDatabase( databaseName: string, @@ -100,9 +140,8 @@ function openDatabase( databaseName, LINKU_DATABASE_VERSION, { - // Pre-release v2 profiles contain a different subset of stores. Check - // each one independently so the migration is additive and never deletes - // or recreates user data. + // Pre-release profiles contain different store subsets. Check each one + // independently so the migration remains additive. upgrade(database, _oldVersion, _newVersion, transaction) { if (!database.objectStoreNames.contains("templates")) { database.createObjectStore("templates"); @@ -130,6 +169,26 @@ function openDatabase( if (!database.objectStoreNames.contains("quarantine")) { database.createObjectStore("quarantine", { keyPath: "id" }); } + + if (!database.objectStoreNames.contains("settings")) { + database.createObjectStore("settings", { keyPath: "key" }); + } + + if (!database.objectStoreNames.contains("outbox")) { + const outbox = database.createObjectStore("outbox", { + keyPath: "key", + }); + outbox.createIndex("by-queued-at", "queuedAt"); + } else { + const outbox = transaction.objectStore("outbox"); + if (!outbox.indexNames.contains("by-queued-at")) { + outbox.createIndex("by-queued-at", "queuedAt"); + } + } + + if (!database.objectStoreNames.contains("syncMeta")) { + database.createObjectStore("syncMeta", { keyPath: "key" }); + } }, blocking() { // Popup, background and extension pages can each hold a connection. diff --git a/src/storage/assetRepository.ts b/src/storage/templates/assetRepository.ts similarity index 74% rename from src/storage/assetRepository.ts rename to src/storage/templates/assetRepository.ts index 1d7e44e2..201c6f1e 100644 --- a/src/storage/assetRepository.ts +++ b/src/storage/templates/assetRepository.ts @@ -1,13 +1,18 @@ -import { getLinkuDb, type StoredAsset } from "@/storage/linkuDb"; -import { allocateMonotonicId } from "@/storage/monotonicId"; +import { + getLinkuDb, + type StoredAsset, +} from "@/storage/indexedDb/linkuDatabase"; +import { allocateMonotonicId } from "@/storage/templates/monotonicId"; +import { createSyncOutboxEntry } from "@/storage/account/syncRepository"; import { MAX_TEMPLATE_NAME_LENGTH, PORTABLE_ICON_PATTERN, } from "@/constants/template"; -import { InvalidTemplateBackupAssetError } from "@/storage/templateBackup"; +import { InvalidTemplateBackupAssetError } from "@/storage/templates/backup"; import { UserFacingError } from "@/errors/userFacingError"; const MAX_ICON_BYTES = 5 * 1024 * 1024; +export const MAX_SYNCED_ICON_BYTES = 512 * 1024; const MAX_ICON_DIMENSION = 256; const ICON_WEBP_QUALITY = 0.9; const RESTORABLE_ICON_TYPES = new Set([ @@ -123,9 +128,9 @@ async function normalizeIconBlob(source: Blob): Promise { } async function assertRestorableIconBlob(source: Blob): Promise { - if (source.size > MAX_ICON_BYTES) { + if (source.size > MAX_SYNCED_ICON_BYTES) { throw new InvalidTemplateBackupAssetError( - `백업 아이콘은 ${MAX_ICON_BYTES / 1024 / 1024}MB 이하여야 합니다.`, + "백업 아이콘은 512KB 이하여야 합니다.", ); } if (!RESTORABLE_ICON_TYPES.has(source.type)) { @@ -160,16 +165,25 @@ async function assertRestorableIconBlob(source: Blob): Promise { async function persistAsset( normalizedName: string, blob: Blob, + options: { expectedId?: string; queueSync?: boolean } = {}, ): Promise { const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()); const id = bytesToHex(new Uint8Array(digest)); + if (options.expectedId && options.expectedId !== id) { + throw new AssetValidationError("아이콘 파일이 동기화 정보와 일치하지 않습니다."); + } const dataUrl = await blobToDataUrl(blob); const createdAt = Date.now(); const database = await getLinkuDb(); - const transaction = database.transaction("assets", "readwrite"); + const transaction = database.transaction(["assets", "outbox"], "readwrite"); const store = transaction.objectStore("assets"); const existing = await store.get(id); if (existing) { + if (options.queueSync !== false) { + await transaction + .objectStore("outbox") + .put(createSyncOutboxEntry("asset", id, "put")); + } await transaction.done; return existing; } @@ -185,13 +199,22 @@ async function persistAsset( createdAt, }; await store.put(asset); + if (options.queueSync !== false) { + await transaction + .objectStore("outbox") + .put(createSyncOutboxEntry("asset", id, "put")); + } await transaction.done; return asset; } export async function saveAsset(name: string, source: Blob): Promise { const normalizedName = normalizeAssetName(name); - return persistAsset(normalizedName, await normalizeIconBlob(source)); + const normalized = await normalizeIconBlob(source); + if (normalized.size > MAX_SYNCED_ICON_BYTES) { + throw new AssetValidationError("아이콘은 변환 후 512KB 이하여야 합니다."); + } + return persistAsset(normalizedName, normalized); } function dataUrlToBlob(dataUrl: string): Blob { @@ -206,13 +229,7 @@ function dataUrlToBlob(dataUrl: string): Blob { return new Blob([bytes], { type: mimeType }); } -/** - * Registers an inline icon image so it becomes a first-class asset. - * - * Shared templates carry their icons as data URLs. Persisting them here is - * what keeps an imported item editable: the editor resolves icons by numeric - * id, and an id that no asset backs cannot be selected or saved again. - */ +/** Registers an inline icon so imported templates remain editable. */ export async function saveAssetFromDataUrl( name: string, dataUrl: string, @@ -220,13 +237,7 @@ export async function saveAssetFromDataUrl( return saveAsset(name, dataUrlToBlob(dataUrl)); } -/** - * Restores bytes that were already normalized before backup. - * - * Re-encoding a backed-up WebP changes its digest and creates a duplicate - * asset on every restore. Validate the stored constraints again, then keep - * the original bytes so the content-addressed id remains stable. - */ +/** Restores validated WebP bytes without changing their content-addressed id. */ export async function restoreAssetFromDataUrl( name: string, dataUrl: string, @@ -254,9 +265,46 @@ export async function restoreAssetFromDataUrl( throw error; } await assertRestorableIconBlob(blob); + if (blob.type !== "image/webp") { + return saveAsset(normalizedName, blob); + } return persistAsset(normalizedName, blob); } +export async function saveRemoteAsset( + name: string, + source: Blob, + expectedId: string, +): Promise { + const normalizedName = normalizeAssetName(name); + if (source.type !== "image/webp" || source.size > MAX_SYNCED_ICON_BYTES) { + throw new AssetValidationError("동기화한 아이콘 형식이 올바르지 않습니다."); + } + await assertRestorableIconBlob(source); + return persistAsset(normalizedName, source, { + expectedId, + queueSync: false, + }); +} + +export async function saveImportedCloudAsset( + name: string, + source: Blob, + expectedId: string, +): Promise { + const normalizedName = normalizeAssetName(name); + if (source.type !== "image/webp" || source.size > MAX_SYNCED_ICON_BYTES) { + throw new AssetValidationError("가져온 아이콘 형식이 올바르지 않습니다."); + } + await assertRestorableIconBlob(source); + return persistAsset(normalizedName, source, { expectedId }); +} + +export async function getAssetById(id: string): Promise { + const database = await getLinkuDb(); + return database.get("assets", id); +} + export async function getAssetByNumericId( numericId: number, ): Promise { diff --git a/src/storage/templateBackup.ts b/src/storage/templates/backup.ts similarity index 95% rename from src/storage/templateBackup.ts rename to src/storage/templates/backup.ts index abc6b8e5..60ca0597 100644 --- a/src/storage/templateBackup.ts +++ b/src/storage/templates/backup.ts @@ -9,11 +9,11 @@ import { MAX_TEMPLATE_NAME_LENGTH, PORTABLE_ICON_PATTERN, UNSAVED_TEMPLATE_ID, -} from "../constants/template.ts"; -import { UserFacingError } from "../errors/userFacingError.ts"; -import type { TemplateItem } from "../types/api.ts"; -import type { StoredTemplate } from "./linkuDb.ts"; -import { normalizeStoredTemplate } from "./templateRecord.ts"; +} from "../../constants/template.ts"; +import { UserFacingError } from "../../errors/userFacingError.ts"; +import type { TemplateItem } from "../../types/api.ts"; +import type { StoredTemplate } from "../indexedDb/linkuDatabase.ts"; +import { normalizeStoredTemplate } from "./record.ts"; export const MAX_TEMPLATE_BACKUP_BYTES = 10 * 1024 * 1024; diff --git a/src/storage/iconReference.ts b/src/storage/templates/iconReference.ts similarity index 95% rename from src/storage/iconReference.ts rename to src/storage/templates/iconReference.ts index 3c404ff0..8bfe0d79 100644 --- a/src/storage/iconReference.ts +++ b/src/storage/templates/iconReference.ts @@ -1,4 +1,4 @@ -import type { Icon, TemplateIcon } from "../types/api.ts"; +import type { Icon, TemplateIcon } from "@/types/api"; export function isRemoteHttpIconUrl(value: string): boolean { try { diff --git a/src/storage/templateIconRepair.ts b/src/storage/templates/iconRepair.ts similarity index 96% rename from src/storage/templateIconRepair.ts rename to src/storage/templates/iconRepair.ts index e4883f0d..b574890f 100644 --- a/src/storage/templateIconRepair.ts +++ b/src/storage/templates/iconRepair.ts @@ -1,12 +1,12 @@ import { getAssetByNumericId, saveAssetFromDataUrl, -} from "@/storage/assetRepository"; +} from "@/storage/templates/assetRepository"; import { isRemoteHttpIconUrl, resolveBundledIconReference, -} from "@/storage/iconReference"; -import type { StoredTemplate } from "@/storage/linkuDb"; +} from "@/storage/templates/iconReference"; +import type { StoredTemplate } from "@/storage/indexedDb/linkuDatabase"; import { PORTABLE_ICON_PATTERN } from "@/constants/template"; import { GENERIC_LINK_ICON_NAME, diff --git a/src/storage/legacyTemplateStorage.ts b/src/storage/templates/legacyLocalStorage.ts similarity index 95% rename from src/storage/legacyTemplateStorage.ts rename to src/storage/templates/legacyLocalStorage.ts index be0c8575..246f600c 100644 --- a/src/storage/legacyTemplateStorage.ts +++ b/src/storage/templates/legacyLocalStorage.ts @@ -1,10 +1,10 @@ -import { getLinkuDb } from "@/storage/linkuDb"; +import { getLinkuDb } from "@/storage/indexedDb/linkuDatabase"; import { LEGACY_DRAFT_KEY, LEGACY_TEMPLATE_INDEX_KEY, LEGACY_TEMPLATE_PREFIX, migrateLegacyTemplateStorage, -} from "@/storage/legacyTemplateMigration"; +} from "@/storage/templates/legacyMigration"; import { debugLog, captureErrorLog, diff --git a/src/storage/legacyTemplateMigration.ts b/src/storage/templates/legacyMigration.ts similarity index 98% rename from src/storage/legacyTemplateMigration.ts rename to src/storage/templates/legacyMigration.ts index 01ad779a..94d5c0bc 100644 --- a/src/storage/legacyTemplateMigration.ts +++ b/src/storage/templates/legacyMigration.ts @@ -1,5 +1,8 @@ -import { DRAFT_SLOT_KEY, type LinkuDb } from "./linkuDb.ts"; -import { parseLegacyTemplateRecord } from "./legacyTemplateRecord.ts"; +import { + DRAFT_SLOT_KEY, + type LinkuDb, +} from "../indexedDb/linkuDatabase.ts"; +import { parseLegacyTemplateRecord } from "./legacyRecord.ts"; export const LEGACY_TEMPLATE_PREFIX = "linku_template_"; export const LEGACY_TEMPLATE_INDEX_KEY = "linku_templates_index"; diff --git a/src/storage/legacyTemplateRecord.ts b/src/storage/templates/legacyRecord.ts similarity index 88% rename from src/storage/legacyTemplateRecord.ts rename to src/storage/templates/legacyRecord.ts index baf8bf61..16dbd8b5 100644 --- a/src/storage/legacyTemplateRecord.ts +++ b/src/storage/templates/legacyRecord.ts @@ -1,5 +1,5 @@ -import type { StoredTemplate } from "./linkuDb.ts"; -import { normalizeStoredTemplate } from "./templateRecord.ts"; +import type { StoredTemplate } from "../indexedDb/linkuDatabase.ts"; +import { normalizeStoredTemplate } from "./record.ts"; export type LegacyTemplateRecordResult = | { ok: true; stored: StoredTemplate; repairs: string[] } diff --git a/src/storage/monotonicId.ts b/src/storage/templates/monotonicId.ts similarity index 100% rename from src/storage/monotonicId.ts rename to src/storage/templates/monotonicId.ts diff --git a/src/storage/quarantine.ts b/src/storage/templates/quarantine.ts similarity index 98% rename from src/storage/quarantine.ts rename to src/storage/templates/quarantine.ts index aac9484c..48a0fb20 100644 --- a/src/storage/quarantine.ts +++ b/src/storage/templates/quarantine.ts @@ -11,7 +11,7 @@ import { type QuarantinedRecord, type QuarantineLocation, type RecordLocation, -} from "@/storage/linkuDb"; +} from "@/storage/indexedDb/linkuDatabase"; import { captureErrorLog } from "@/utils/logger"; export interface QuarantineInput { diff --git a/src/storage/templateRecord.ts b/src/storage/templates/record.ts similarity index 92% rename from src/storage/templateRecord.ts rename to src/storage/templates/record.ts index 58cdce0e..7bfbb3c8 100644 --- a/src/storage/templateRecord.ts +++ b/src/storage/templates/record.ts @@ -16,15 +16,19 @@ * LinKU versions legitimately wrote. */ -import type { Template, TemplateIcon, TemplateItem } from "../types/api.ts"; -import type { StoredTemplate } from "./linkuDb.ts"; +import type { + Template, + TemplateIcon, + TemplateItem, +} from "../../types/api.ts"; +import type { StoredTemplate } from "../indexedDb/linkuDatabase.ts"; import { GRID_COLUMNS, GRID_ROWS, MAX_SITE_URL_LENGTH, MAX_TEMPLATE_ITEMS, MAX_TEMPLATE_NAME_LENGTH, -} from "../constants/template.ts"; +} from "../../constants/template.ts"; export interface NormalizeResult { /** Normalized record, or null when the record must be quarantined. */ @@ -43,21 +47,27 @@ export interface NormalizeOptions { } const IMPORTED_TEMPLATE_SUFFIX = " (가져옴)"; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; export function normalizeTemplateName(value: unknown): string { const name = typeof value === "string" ? value.trim() : ""; return (name || "이름 없는 템플릿").slice(0, MAX_TEMPLATE_NAME_LENGTH); } -export function formatImportedTemplateName(name: string): string { +export function formatImportedTemplateName( + name: string, + suffix = IMPORTED_TEMPLATE_SUFFIX, +): string { + const normalizedSuffix = suffix.startsWith(" ") ? suffix : ` ${suffix}`; const normalizedName = normalizeTemplateName(name); - const baseName = normalizedName.endsWith(IMPORTED_TEMPLATE_SUFFIX) - ? normalizedName.slice(0, -IMPORTED_TEMPLATE_SUFFIX.length) + const baseName = normalizedName.endsWith(normalizedSuffix) + ? normalizedName.slice(0, -normalizedSuffix.length) : normalizedName; return `${baseName.slice( 0, - MAX_TEMPLATE_NAME_LENGTH - IMPORTED_TEMPLATE_SUFFIX.length, - )}${IMPORTED_TEMPLATE_SUFFIX}`; + MAX_TEMPLATE_NAME_LENGTH - normalizedSuffix.length, + )}${normalizedSuffix}`; } function isRecord(value: unknown): value is Record { @@ -240,11 +250,11 @@ export function normalizeStoredTemplate( const now = new Date().toISOString(); const sourceId = - typeof source.id === "string" && source.id.trim().length > 0 + typeof source.id === "string" && UUID_PATTERN.test(source.id.trim()) ? source.id.trim() : null; if (!sourceId) { - repairs.push("템플릿 고유 식별자가 없어 새로 부여했습니다."); + repairs.push("템플릿 고유 식별자를 UUID로 정리했습니다."); } else if (sourceId !== source.id) { repairs.push("템플릿 고유 식별자의 공백을 정리했습니다."); } diff --git a/src/utils/templateStorage.ts b/src/storage/templates/repository.ts similarity index 74% rename from src/utils/templateStorage.ts rename to src/storage/templates/repository.ts index 54ba72a2..0727e306 100644 --- a/src/utils/templateStorage.ts +++ b/src/storage/templates/repository.ts @@ -15,25 +15,29 @@ * 3. inline icons are registered as assets so imported items stay editable. */ -import { restoreAssetFromDataUrl } from "@/storage/assetRepository"; +import { restoreAssetFromDataUrl } from "@/storage/templates/assetRepository"; import { getLinkuDb, type RecordLocation, type StoredTemplate, -} from "@/storage/linkuDb"; +} from "@/storage/indexedDb/linkuDatabase"; +import { + createSyncOutboxEntry, + isTemplatePublished, +} from "@/storage/account/syncRepository"; import { UNSAVED_TEMPLATE_ID } from "@/constants/template"; -import { moveRecordToQuarantineSafely } from "@/storage/quarantine"; -import { allocateMonotonicId } from "@/storage/monotonicId"; +import { moveRecordToQuarantineSafely } from "@/storage/templates/quarantine"; +import { allocateMonotonicId } from "@/storage/templates/monotonicId"; import { migrateLegacyTemplates, removeLegacyTemplateSource, -} from "@/storage/legacyTemplateStorage"; -import { repairTemplateIcons } from "@/storage/templateIconRepair"; +} from "@/storage/templates/legacyLocalStorage"; +import { repairTemplateIcons } from "@/storage/templates/iconRepair"; import { formatImportedTemplateName, normalizeTemplateName, normalizeStoredTemplate, -} from "@/storage/templateRecord"; +} from "@/storage/templates/record"; import { assertTemplateBackupSize, isTemplateBackupValidationError, @@ -42,28 +46,36 @@ import { selectReferencedBackupAssets, type RestoredAssetReference, type TemplateBackupV1, -} from "@/storage/templateBackup"; +} from "@/storage/templates/backup"; import type { Template, TemplateItem } from "@/types/api"; import { debugLog, captureErrorLog, captureWarnLog, warnLog } from "@/utils/logger"; import { recordBreadcrumb } from "@/monitoring"; -import { portablePayloadToTemplate } from "@/utils/templateShare"; -import { - validateTemplateSharePayload, - validateTemplateSharePayloadImages, -} from "@/utils/templateShareCodec"; -import type { TemplateSharePayloadV1 } from "@/types/templateShare"; export { isTemplateBackupValidationError, MAX_TEMPLATE_BACKUP_BYTES, -} from "@/storage/templateBackup"; +} from "@/storage/templates/backup"; export { countQuarantinedRecords, listQuarantinedRecords, -} from "@/storage/quarantine"; +} from "@/storage/templates/quarantine"; let migrationPromise: Promise | undefined; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +function ensureTemplateUuid(value: string): string { + return UUID_PATTERN.test(value) ? value : crypto.randomUUID(); +} + +export class PublishedTemplateDeleteError extends Error { + constructor() { + super("게시 중인 템플릿은 게시를 내린 뒤 삭제해 주세요."); + this.name = "PublishedTemplateDeleteError"; + } +} + function isQuotaError(error: unknown): boolean { return ( error instanceof DOMException && @@ -101,7 +113,14 @@ async function writeRecord( value: StoredTemplate, ): Promise { const database = await getLinkuDb(); - await database.put("templates", value, at.key); + const transaction = database.transaction(["templates", "outbox"], "readwrite"); + await Promise.all([ + transaction.objectStore("templates").put(value, at.key), + transaction.objectStore("outbox").put( + createSyncOutboxEntry("template", value.template.id, "put"), + ), + ]); + await transaction.done; } async function readStoredRecord(at: RecordLocation): Promise { @@ -161,7 +180,10 @@ export async function saveLocalTemplate( ): 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"); try { @@ -174,7 +196,7 @@ export async function saveLocalTemplate( template: { ...template, templateId, - id: template.id || crypto.randomUUID(), + id: ensureTemplateUuid(template.id), name: normalizeTemplateName(template.name), syncStatus: "local", }, @@ -186,6 +208,9 @@ export async function saveLocalTemplate( }; await store.put(stored, templateId); + await transaction + .objectStore("outbox") + .put(createSyncOutboxEntry("template", stored.template.id, "put")); await transaction.done; return stored; } catch (error) { @@ -217,24 +242,42 @@ export async function deleteLocalTemplate( templateId: number, ): Promise { await ensureMigration(); + const database = await getLinkuDb(); + const current = await database.get("templates", templateId); + if (current && (await isTemplatePublished(current.template.id))) { + throw new PublishedTemplateDeleteError(); + } + // Remove the rollback source before IndexedDB. If this write is blocked, // keep the active record rather than reporting a deletion that can later // reappear during a fresh migration. removeLegacyTemplateSource(templateId); - const database = await getLinkuDb(); - await database.delete("templates", templateId); + const transaction = database.transaction( + ["templates", "outbox"], + "readwrite", + ); + const templates = transaction.objectStore("templates"); + const stored = await templates.get(templateId); + await templates.delete(templateId); + if (stored) { + await transaction + .objectStore("outbox") + .put(createSyncOutboxEntry("template", stored.template.id, "delete")); + } + await transaction.done; } /** * Stores a copy of an existing template under a freshly allocated id. * - * Used by both the gallery ("이 템플릿 추가") and shared-template imports, so - * the id allocation and icon registration stay in one place. + * Used by gallery clones and backup restores, so id allocation and icon + * registration stay in one place. */ export async function importTemplateCopy( template: Template, stagingItems: TemplateItem[] = [], + options: { nameSuffix?: string } = {}, ): Promise { await ensureMigration(); @@ -254,7 +297,10 @@ export async function importTemplateCopy( const withIcons = repaired.stored; const database = await getLinkuDb(); - const transaction = database.transaction("templates", "readwrite"); + const transaction = database.transaction( + ["templates", "outbox"], + "readwrite", + ); const store = transaction.objectStore("templates"); try { @@ -265,7 +311,10 @@ export async function importTemplateCopy( ...withIcons.template, id: crypto.randomUUID(), templateId, - name: formatImportedTemplateName(withIcons.template.name), + name: formatImportedTemplateName( + withIcons.template.name, + options.nameSuffix, + ), cloned: true, syncStatus: "local", createdAt: now, @@ -276,6 +325,9 @@ export async function importTemplateCopy( }; await store.put(stored, templateId); + await transaction + .objectStore("outbox") + .put(createSyncOutboxEntry("template", stored.template.id, "put")); await transaction.done; return stored; } catch (error) { @@ -283,20 +335,57 @@ export async function importTemplateCopy( } } -export async function importSharedTemplate( - payload: TemplateSharePayloadV1, +export async function findTemplateBySyncId( + resourceId: string, +): Promise { + const templates = await listLocalTemplates(); + return templates.find((stored) => stored.template.id === resourceId) ?? null; +} + +export async function saveRemoteTemplate( + template: Template, stagingItems: TemplateItem[] = [], + existingTemplateId?: number, ): Promise { - validateTemplateSharePayload(payload); - await validateTemplateSharePayloadImages(payload); - return importTemplateCopy(portablePayloadToTemplate(payload), stagingItems); + await ensureMigration(); + const database = await getLinkuDb(); + const transaction = database.transaction("templates", "readwrite"); + const store = transaction.objectStore("templates"); + + try { + const templateId = existingTemplateId ?? (await allocateMonotonicId(store)); + const stored: StoredTemplate = { + template: { + ...template, + templateId, + name: normalizeTemplateName(template.name), + syncStatus: "synced", + }, + stagingItems, + metadata: { lastSaved: Date.now(), savedLocally: true }, + }; + await store.put(stored, templateId); + await transaction.done; + return stored; + } catch (error) { + throw toStorageError(error, "동기화한 템플릿을 저장하지 못했습니다."); + } +} + +export async function removeLocalTemplateWithoutSync( + templateId: number, +): Promise { + await ensureMigration(); + removeLegacyTemplateSource(templateId); + const database = await getLinkuDb(); + await database.delete("templates", templateId); } /** * Exports every local template and the user icons those records reference. * - * Sharing covers one template at a time; without a whole-store export a lost - * Chrome profile takes every template with it, and no server holds a copy. + * Account sync is asynchronous, so whole-store backup remains the explicit + * recovery path for local data that has not reached the cloud yet. */ export async function createTemplateBackup(): Promise< TemplateBackupV1 diff --git a/src/sync/templateDocument.ts b/src/sync/templateDocument.ts new file mode 100644 index 00000000..a0582082 --- /dev/null +++ b/src/sync/templateDocument.ts @@ -0,0 +1,303 @@ +import { z } from "zod"; +import { + GENERIC_LINK_ICON_NAME, + getBundledTemplateIcons, +} from "@/constants/templateIcons"; +import { + GRID_COLUMNS, + GRID_ROWS, + MAX_SITE_URL_LENGTH, + MAX_TEMPLATE_ITEMS, + MAX_TEMPLATE_NAME_LENGTH, + UNSAVED_TEMPLATE_ID, +} from "@/constants/template"; +import { + getAssetById, + getAssetByNumericId, + saveAssetFromDataUrl, +} from "@/storage/templates/assetRepository"; +import { resolveBundledIconReference } from "@/storage/templates/iconReference"; +import type { StoredAsset, StoredTemplate } from "@/storage/indexedDb/linkuDatabase"; +import type { + CloudTemplateDocumentV1, + CloudTemplateIcon, + CloudTemplateItem, + PublishedTemplateSnapshotV1, +} from "@/types/account"; +import type { Template, TemplateIcon, TemplateItem } from "@/types/api"; + +export const MAX_CLOUD_TEMPLATE_BYTES = 256 * 1024; +const HASH_PATTERN = /^[0-9a-f]{64}$/u; + +const httpUrlSchema = z + .string() + .max(MAX_SITE_URL_LENGTH) + .url() + .refine((value) => { + const protocol = new URL(value).protocol; + return protocol === "https:" || protocol === "http:"; + }); + +const iconSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("builtin"), + key: z.string().trim().min(1).max(MAX_TEMPLATE_NAME_LENGTH), + }) + .strict(), + z + .object({ + kind: z.literal("asset"), + hash: z.string().regex(HASH_PATTERN), + name: z.string().trim().min(1).max(MAX_TEMPLATE_NAME_LENGTH), + }) + .strict(), +]); + +const itemSchema = z + .object({ + templateItemId: z.number().int().min(-2_147_483_648).max(2_147_483_647), + name: z.string().trim().min(1).max(MAX_TEMPLATE_NAME_LENGTH), + siteUrl: httpUrlSchema, + position: z + .object({ + x: z.number().int().min(0).max(GRID_COLUMNS - 1), + y: z.number().int().min(0).max(GRID_ROWS - 1), + }) + .strict(), + size: z + .object({ + width: z.number().int().min(1).max(GRID_COLUMNS), + height: z.number().int().min(1).max(GRID_ROWS), + }) + .strict(), + icon: iconSchema, + }) + .strict() + .refine((item) => item.templateItemId !== 0) + .refine((item) => item.position.x + item.size.width <= GRID_COLUMNS); + +const commonTemplateSchema = z + .object({ + version: z.literal(1), + name: z.string().trim().min(1).max(MAX_TEMPLATE_NAME_LENGTH), + height: z.number().int().min(1).max(GRID_ROWS), + items: z.array(itemSchema).max(MAX_TEMPLATE_ITEMS), + }) + .strict() + .refine((template) => + template.items.every( + (item) => item.position.y + item.size.height <= template.height, + ), + ); + +export const publishedTemplateSnapshotSchema = commonTemplateSchema; + +export const cloudTemplateDocumentSchema = commonTemplateSchema + .safeExtend({ + cloned: z.boolean(), + createdAt: z.string().datetime({ offset: true }), + updatedAt: z.string().datetime({ offset: true }), + stagingItems: z.array(itemSchema).max(MAX_TEMPLATE_ITEMS), + }) + .refine((template) => + template.stagingItems.every( + (item) => item.position.y + item.size.height <= template.height, + ), + ); + +export class MissingCloudAssetError extends Error { + constructor() { + super("템플릿에 필요한 아이콘을 동기화하지 못했습니다."); + this.name = "MissingCloudAssetError"; + } +} + +function assertDocumentSize(value: unknown): void { + if (new TextEncoder().encode(JSON.stringify(value)).byteLength > MAX_CLOUD_TEMPLATE_BYTES) { + throw new Error("템플릿 데이터가 256KB를 초과합니다."); + } +} + +function bundledIcon(key: string): TemplateIcon { + const icons = getBundledTemplateIcons(); + const icon = + icons.find((candidate) => candidate.name === key) ?? + icons.find((candidate) => candidate.name === GENERIC_LINK_ICON_NAME)!; + return { + iconId: icon.id, + iconName: icon.name, + iconUrl: icon.imageUrl, + }; +} + +async function toCloudIcon(icon: TemplateIcon): Promise { + const bundled = resolveBundledIconReference(icon, getBundledTemplateIcons()); + if (bundled) return { kind: "builtin", key: bundled.name }; + + let asset = await getAssetByNumericId(icon.iconId); + if (!asset && icon.iconUrl.startsWith("data:image/")) { + asset = await saveAssetFromDataUrl(icon.iconName, icon.iconUrl); + } + return asset + ? { kind: "asset", hash: asset.id, name: asset.name } + : { kind: "builtin", key: GENERIC_LINK_ICON_NAME }; +} + +async function toCloudItem(item: TemplateItem): Promise { + return { + templateItemId: item.templateItemId, + name: item.name, + siteUrl: item.siteUrl, + position: item.position, + size: item.size, + icon: await toCloudIcon(item.icon), + }; +} + +export async function createCloudTemplateDocument( + stored: StoredTemplate, +): Promise { + const document: CloudTemplateDocumentV1 = { + version: 1, + name: stored.template.name, + height: stored.template.height, + cloned: stored.template.cloned, + createdAt: stored.template.createdAt, + updatedAt: stored.template.updatedAt, + items: await Promise.all(stored.template.items.map(toCloudItem)), + stagingItems: await Promise.all(stored.stagingItems.map(toCloudItem)), + }; + const parsed = cloudTemplateDocumentSchema.parse(document); + assertDocumentSize(parsed); + return parsed; +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, sortJson(entry)]), + ); +} + +export async function hashCloudTemplate(value: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(sortJson(value))); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +export function createPublishedSnapshot( + document: CloudTemplateDocumentV1, +): PublishedTemplateSnapshotV1 { + return { + version: 1, + name: document.name, + height: document.height, + items: document.items, + }; +} + +export async function hashPublishedTemplate( + document: CloudTemplateDocumentV1, +): Promise { + return hashCloudTemplate(createPublishedSnapshot(document)); +} + +export function parseCloudTemplateDocument(value: unknown): CloudTemplateDocumentV1 { + assertDocumentSize(value); + return cloudTemplateDocumentSchema.parse(value); +} + +export function parsePublishedTemplateSnapshot( + value: unknown, +): PublishedTemplateSnapshotV1 { + assertDocumentSize(value); + return publishedTemplateSnapshotSchema.parse(value); +} + +export type CloudAssetResolver = ( + hash: string, + name: string, +) => Promise | undefined>; + +const resolveLocalAsset: CloudAssetResolver = async (hash) => getAssetById(hash); + +async function fromCloudIcon( + icon: CloudTemplateIcon, + resolveAsset: CloudAssetResolver, +): Promise { + if (icon.kind === "builtin") return bundledIcon(icon.key); + const asset = await resolveAsset(icon.hash, icon.name); + if (!asset) throw new MissingCloudAssetError(); + return { + iconId: asset.numericId, + iconName: asset.name, + iconUrl: asset.dataUrl, + }; +} + +async function fromCloudItem( + item: CloudTemplateItem, + resolveAsset: CloudAssetResolver, +): Promise { + return { + templateItemId: item.templateItemId, + name: item.name, + siteUrl: item.siteUrl, + position: item.position, + size: item.size, + icon: await fromCloudIcon(item.icon, resolveAsset), + }; +} + +export async function cloudDocumentToTemplate( + id: string, + value: unknown, + resolveAsset: CloudAssetResolver = resolveLocalAsset, +): Promise<{ template: Template; stagingItems: TemplateItem[] }> { + const document = parseCloudTemplateDocument(value); + return { + template: { + id, + templateId: UNSAVED_TEMPLATE_ID, + name: document.name, + height: document.height, + cloned: document.cloned, + createdAt: document.createdAt, + updatedAt: document.updatedAt, + items: await Promise.all( + document.items.map((item) => fromCloudItem(item, resolveAsset)), + ), + syncStatus: "synced", + }, + stagingItems: await Promise.all( + document.stagingItems.map((item) => fromCloudItem(item, resolveAsset)), + ), + }; +} + +export async function publishedSnapshotToTemplate( + value: unknown, + resolveAsset: CloudAssetResolver, +): Promise