From f337c20096183938a3eb28f27cb541654df04f08 Mon Sep 17 00:00:00 2001 From: syui Date: Tue, 3 Jun 2025 13:27:37 +0900 Subject: [PATCH] fix --- .claude/settings.local.json | 25 +- .env.development | 18 + README.md | 316 ++++++++- api/app/core/config.py | 8 +- api/app/repositories/card.py | 45 ++ api/app/routes/cards.py | 44 +- cloudflared-config.production.yml | 18 + cloudflared-config.yml | 33 + ios/AiCard/AiCard/Models/AIModels.swift | 73 +++ ios/AiCard/AiCard/Services/APIClient.swift | 128 +++- .../AiCard/Services/AtprotoOAuthService.swift | 355 ++++++++++ ios/AiCard/AiCard/Services/AuthManager.swift | 53 +- web/package.json | 21 +- web/public/.well-known/jwks.json | 14 + web/public/client-metadata.json | 23 + web/src/App.css | 110 +++- web/src/App.tsx | 350 +++++++++- web/src/components/CardBox.tsx | 141 ++++ web/src/components/CollectionAnalysis.tsx | 133 ++++ web/src/components/GachaAnimation.tsx | 56 +- web/src/components/GachaStats.tsx | 144 ++++ web/src/components/Login.tsx | 180 +++-- web/src/components/OAuthCallback.tsx | 258 ++++++++ web/src/components/OAuthCallbackPage.tsx | 42 ++ web/src/main.tsx | 14 +- web/src/services/api.ts | 86 ++- web/src/services/atproto-oauth.ts | 616 ++++++++++++++++++ web/src/styles/CardBox.css | 179 +++++ web/src/styles/CollectionAnalysis.css | 172 +++++ web/src/styles/GachaAnimation.css | 54 ++ web/src/styles/GachaStats.css | 219 +++++++ web/src/styles/Login.css | 93 ++- web/src/utils/oauth-endpoints.ts | 141 ++++ web/src/utils/oauth-keys.ts | 204 ++++++ web/vite.config.ts | 20 +- 35 files changed, 4217 insertions(+), 169 deletions(-) create mode 100644 .env.development create mode 100644 cloudflared-config.production.yml create mode 100644 cloudflared-config.yml create mode 100644 ios/AiCard/AiCard/Models/AIModels.swift create mode 100644 ios/AiCard/AiCard/Services/AtprotoOAuthService.swift create mode 100644 web/public/.well-known/jwks.json create mode 100644 web/public/client-metadata.json create mode 100644 web/src/components/CardBox.tsx create mode 100644 web/src/components/CollectionAnalysis.tsx create mode 100644 web/src/components/GachaStats.tsx create mode 100644 web/src/components/OAuthCallback.tsx create mode 100644 web/src/components/OAuthCallbackPage.tsx create mode 100644 web/src/services/atproto-oauth.ts create mode 100644 web/src/styles/CardBox.css create mode 100644 web/src/styles/CollectionAnalysis.css create mode 100644 web/src/styles/GachaStats.css create mode 100644 web/src/utils/oauth-endpoints.ts create mode 100644 web/src/utils/oauth-keys.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 14612a0..a217b89 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,30 @@ "allow": [ "WebFetch(domain:card.syui.ai)", "Bash(mkdir:*)", - "Bash(chmod:*)" + "Bash(chmod:*)", + "Bash(./start_server.sh:*)", + "Bash(npm run dev:*)", + "Bash(npm install)", + "WebFetch(domain:github.com)", + "Bash(npm run build:*)", + "Bash(npm run preview:*)", + "Bash(curl:*)", + "Bash(sudo kill:*)", + "Bash(launchctl:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "Bash(find:*)", + "Bash(cloudflared:*)", + "Bash(grep:*)", + "Bash(nslookup:*)", + "Bash(sqlite3:*)", + "Bash(kill:*)", + "Bash(pkill:*)", + "WebFetch(domain:docs.bsky.app)", + "Bash(npm install:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:www.npmjs.com)", + "Bash(rm:*)" ], "deny": [] } diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..16aab3d --- /dev/null +++ b/.env.development @@ -0,0 +1,18 @@ +# Development configuration for ai.card +# This file is used for local development + +# Web Frontend Configuration +VITE_WEB_HOST=http://localhost:5173 +VITE_API_HOST=http://localhost:8000 +VITE_WEB_PORT=5173 + +# API Backend Configuration +API_HOST=localhost +API_PORT=8000 + +# OAuth Configuration +VITE_OAUTH_CLIENT_NAME=ai.card +VITE_OAUTH_REDIRECT_PATH=/oauth/callback + +# Feature Flags +VITE_ENABLE_AI_FEATURES=true \ No newline at end of file diff --git a/README.md b/README.md index be706f2..924c172 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,315 @@ # ai.card -atprotoベースのカードゲームシステム +🎴 atproto基盤カードゲームシステム × 🧠 ai.gpt AI統合 ## 概要 ai.cardは、ユーザーがデータを所有する分散型カードゲームです。 -- atprotoアカウントと連携 -- データはユーザーのPDSに保存 -- yui-systemによるuniqueカード実装 -- iOS/Web/APIの統合プロジェクト +- 🤖 **AI統合**: ai.gpt MCPサーバー経由でAI機能拡張 +- 🔗 **atproto連携**: 分散SNSとのデータ同期 +- 📱 **クロスプラットフォーム**: iOS/Web統合クライアント +- 🎯 **yui-system**: 個人の唯一性を保証するユニークカード実装 + +## アーキテクチャ + +### 基本構成(ai.card独立動作) +``` +iOS/Web Client + ↓ HTTP API +ai.card API Server (port 8000) 🎴 基本カードゲーム + ↓ +SQLite/PostgreSQL + atproto PDS +``` + +### AI拡張構成(オプション) +``` +iOS/Web Client + ↓ HTTP API (基本機能) +ai.card API Server (port 8000) 🎴 カードゲーム + ↓ +SQLite/PostgreSQL + atproto PDS + +iOS/Web Client (AI機能のみ) + ↓ HTTP API (AI拡張) +ai.gpt MCP Server (port 8001) 🧠 AI分析・統計 + ↓ HTTP Client +ai.card MCP Server (port 8000) +``` + +**設計思想**: ai.cardは完全に独立して動作し、ai.gptは必要に応じてai.cardと連携するオプション機能 ## 技術スタック -- **API**: Python/FastAPI + fastapi_mcp -- **Web**: モダンJavaScript framework -- **iOS**: Swift/SwiftUI -- **データストア**: atproto collection + ローカルキャッシュ -- **認証**: atproto OAuth +### バックエンド +- **ai.card API**: Python/FastAPI(独立動作) +- **MCP統合**: オプションでai.gpt連携 +- **データベース**: SQLite (開発) / PostgreSQL (本番) +- **認証**: atproto OAuth 2.1 + レガシーアプリパスワード + +### フロントエンド +- **Web**: React + TypeScript + Vite +- **iOS**: Swift/SwiftUI + Combine +- **基本機能**: ガチャ・コレクション・統計(ai.card単体) +- **AI拡張**: コレクション分析・AI統計(ai.gpt連携時のみ) ## プロジェクト構造 ``` ai.card/ -├── api/ # FastAPI backend -├── web/ # Web frontend -├── ios/ # iOS app -├── docs/ # Documentation -└── scripts/ # Utility scripts +├── api/ # FastAPI + MCP Server +│ ├── app/ +│ │ ├── main.py # エントリポイント +│ │ ├── mcp_server.py # MCP統合サーバー +│ │ ├── models/ # データモデル +│ │ ├── routes/ # REST API +│ │ └── services/ # ビジネスロジック +│ └── requirements.txt +├── web/ # React Web Client +│ ├── src/ +│ │ ├── components/ # UI コンポーネント +│ │ ├── services/ # API クライアント (ai.gpt経由) +│ │ └── styles/ # CSS スタイル +│ └── package.json +├── ios/ # iOS SwiftUI App +│ └── AiCard/ +│ ├── Models/ # データモデル + AI統合 +│ ├── Services/ # API クライアント (ai.gpt経由) +│ └── Views/ # SwiftUI ビュー +├── docs/ # ドキュメント +└── scripts/ # 環境セットアップ ``` -## 機能 +## 🧠 AI機能 -- カードガチャシステム -- キラカード(0.1%) -- uniqueカード(0.0001% - 隠し機能) -- atprotoデータ同期 -- 改ざん防止機構 +### コレクション分析 +- **AIによる自動分析**: レアリティ分布・コレクション評価 +- **個人化推奨**: ユーザーの収集パターンに基づく提案 +- **スコアリング**: 総合的なコレクション価値算出 + +### ガチャ統計 +- **リアルタイム統計**: 全体・個人のガチャ成功率 +- **トレンド分析**: 時系列での引き運分析 +- **活動履歴**: 最近のガチャ結果表示 ## セットアップ -### API +### 基本セットアップ(ai.card単体) + +#### 1. ai.card サーバー起動 ```bash -cd api -pip install -r requirements.txt -uvicorn app.main:app --reload +# 自動セットアップ +./setup_venv.sh + +# サーバー起動 +./start_server.sh +# → http://localhost:8000 で起動 ``` -### Web +#### 2. Web クライアント起動 ```bash cd web npm install npm run dev +# → http://localhost:5173 で起動(基本機能利用可能) ``` +#### 3. iOS 開発 +```bash +# Xcodeでプロジェクトを開く +open ios/AiCard/AiCard.xcodeproj +# → 基本機能(ガチャ・コレクション・統計)利用可能 +``` + +### AI拡張セットアップ(オプション) + +#### 4. ai.gpt サーバー起動(AI機能用) +```bash +# ai.gptプロジェクトで実行 +cd ../ +aigpt server --port 8001 +# → http://localhost:8001 で起動 +# → AI分析・統計機能が利用可能に +``` + +## 🔐 atproto OAuth認証(実装完了) + +### OAuth 2.1 + DPoP認証システム + +#### 認証フロー +1. **ユーザー認証**: Blueskyハンドル入力 (例: syui.ai) +2. **OAuth認証**: BrowserOAuthClient による認証リダイレクト +3. **セッション管理**: DPoP保護されたトークンでセキュア認証 +4. **Handle表示**: DIDからHandleの自動解決 + +#### 実装詳細 +```typescript +// OAuth設定 +const oauthClient = await BrowserOAuthClient.load({ + clientId: clientId, + handleResolver: 'https://bsky.social', +}); + +// 認証実行(重要: transition:genericスコープが必須) +const authUrl = await this.oauthClient.authorize(handle, { + scope: 'atproto transition:generic', // カスタムコレクション用 +}); + +// セッション取得 +const result = await oauthClient.init(); +const agent = new Agent(result.session); // 公式推奨方法 +``` + +#### カスタムコレクション対応 +- **コレクション**: `ai.card.box` +- **必要スコープ**: `transition:generic`(カスタムレコードタイプ用) +- **レコード例**: +```json +{ + "$type": "ai.card.box", + "cards": [...], + "total_cards": 25, + "updated_at": "2025-01-06T..." +} +``` + +#### 確認方法 +```bash +# atproto レコード確認 +curl -sL "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=syui.ai&collection=ai.card.box" +``` + +### データ主権の実現 +- **ユーザーがデータを所有**: atproto networkでレコード管理 +- **分散型アーキテクチャ**: 中央集権的サーバーに依存しない +- **相互運用性**: 他のatproto対応アプリとのデータ共有可能 + +## 使い方 + +### Web アプリケーション + +#### 基本機能(ai.card単体) +1. **ガチャ**: 通常/プレミアムガチャでカード取得 +2. **コレクション**: 保有カード一覧・詳細表示 +3. **📊 統計**: ガチャ統計・レアリティ分布 + +#### AI拡張機能(ai.gpt連携時) +4. **🧠 AI分析**: コレクション分析・推奨システム +5. **📊 統計 (AI強化)**: 高度な統計・トレンド分析 + +### iOS アプリケーション +- Web版と同等の機能をネイティブUIで提供 +- SwiftUI + Combine による reactive UI +- ai.card独立動作 + オプションでai.gpt AI機能 + +## API エンドポイント + +### ai.card 直接API(基本機能) +| エンドポイント | 説明 | メソッド | +|---------------|------|---------| +| `/api/v1/cards/draw` | ガチャ実行 | POST | +| `/api/v1/cards/user/{did}` | カード一覧取得 | GET | +| `/api/v1/cards/{id}` | カード詳細 | GET | +| `/api/v1/cards/stats` | ガチャ統計 | GET | +| `/api/v1/cards/unique` | ユニークカード | GET | +| `/api/v1/health` | システム状態 | GET | + +### ai.gpt MCP Tools(AI拡張機能) +| エンドポイント | 説明 | パラメータ | +|---------------|------|-----------| +| `/card_analyze_collection` | AI分析 | did | +| `/card_get_gacha_stats` | AI統計 | - | + +### 依存関係 +- **ai.card**: 完全独立動作(依存なし) +- **ai.gpt**: ai.cardに依存(オプション機能として) + ## 開発状況 -- [ ] API基盤 -- [ ] カードデータモデル -- [ ] ガチャシステム -- [ ] atproto連携 -- [ ] Web UI -- [ ] iOS app \ No newline at end of file +### ✅ 完成済み +- [x] **MCP Server統合**: ai.card独立サーバー + ai.gpt連携 +- [x] **SQLite基盤**: カード・ガチャ・ユーザー管理 +- [x] **AI機能**: コレクション分析・ガチャ統計 +- [x] **Web UI**: React SPA + AI機能タブ +- [x] **iOS基盤**: SwiftUI + ai.gpt連携APIクライアント +- [x] **OAuth 2.1認証**: atproto OAuth + DPoP認証実装完了 +- [x] **atproto データバックアップ**: ai.card.boxコレクションへの保存機能 + +### 🚧 進行中 +- [ ] **atproto データ復元**: ai.card.boxからローカルへの復元機能 +- [ ] **ユニークカード**: yui-system実装 +- [ ] **リアルタイム機能**: WebSocket対応 + +### 🎯 今後の予定 + +#### 次回作業項目(優先度高) +- [ ] **atproto データ復元機能**: ai.card.boxからローカルSQLiteへの復元 +- [ ] **CardBox コンポーネント**: atproto レコード表示UI +- [ ] **同期機能**: ローカル ↔ atproto 双方向同期 +- [ ] **iOS OAuth対応**: SwiftUIでのatproto認証実装 + +#### 将来的な拡張 +- [ ] **本番デプロイ**: Cloudflare + PostgreSQL +- [ ] **ai.verse統合**: 3Dメタバース連携 +- [ ] **分散SNS**: atproto PDS自動投稿 +- [ ] **マルチユーザー対応**: 他ユーザーのコレクション閲覧 + +## トラブルシューティング + +### OAuth認証エラー + +#### `Missing required scope: transition:generic` +```typescript +// 解決方法: スコープに transition:generic を追加 +const authUrl = await this.oauthClient.authorize(handle, { + scope: 'atproto transition:generic', // ✅ 正しい + // scope: 'atproto', // ❌ 不十分 +}); +``` + +#### Handle が "unknown" と表示される +```typescript +// 原因: BrowserOAuthClient の使用方法が間違っている +// 解決方法: sessionオブジェクトを直接Agentに渡す +const agent = new Agent(result.session); // ✅ 公式推奨 +// new Agent({service: '...', fetch: session.dpopFetch}); // ❌ 非推奨 +``` + +#### カスタムコレクションへの書き込みエラー +```bash +# 確認: OAuth スコープが正しく設定されているか +# ブラウザコンソールで確認: +console.log(atprotoOAuthService.getSession()); +# → scope: "atproto transition:generic" が含まれているか確認 +``` + +### ai.gpt連携エラー +```bash +# ai.gptサーバーが起動しているか確認 +curl http://localhost:8001/health + +# ai.cardサーバーが起動しているか確認 +curl http://localhost:8000/health +``` + +### データベースエラー +```bash +# データベース初期化 +cd api +~/.config/syui/ai/card/venv/bin/python init_db.py +``` + +### atproto データ確認 +```bash +# バックアップされたレコードを確認 +curl -sL "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo={YOUR_HANDLE}&collection=ai.card.box" + +# レコード詳細取得 +curl -sL "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo={YOUR_HANDLE}&collection=ai.card.box&rkey=self" +``` + +## 貢献 + +ai.card は ai.gpt エコシステムの一部として開発されています。 +- [ai.gpt 統合設計書](../CLAUDE.md) +- [MCP統合作業報告](./docs/MCP_INTEGRATION_SUMMARY.md) +- [AI統合ガイド](../docs/AI_CARD_INTEGRATION.md) \ No newline at end of file diff --git a/api/app/core/config.py b/api/app/core/config.py index 90c4246..30ec701 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -35,7 +35,13 @@ class Settings(BaseSettings): max_unique_cards: int = 1000 # Maximum number of unique cards # CORS - cors_origins: list[str] = ["http://localhost:3000", "https://card.syui.ai"] + cors_origins: list[str] = [ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:4173", + "https://card.syui.ai", + "https://xxxcard.syui.ai" + ] # Security secret_key: str = "your-secret-key-change-this-in-production" diff --git a/api/app/repositories/card.py b/api/app/repositories/card.py index 6e12ca1..2d4c9e9 100644 --- a/api/app/repositories/card.py +++ b/api/app/repositories/card.py @@ -92,6 +92,51 @@ class CardRepository(BaseRepository[UserCard]): obtained_at=card.obtained_at ) self.session.add(registry) + + async def get_total_card_count(self) -> int: + """Get total number of cards obtained""" + result = await self.session.execute( + select(func.count(UserCard.id)) + ) + return result.scalar() or 0 + + async def get_cards_by_rarity(self) -> dict: + """Get card count by rarity""" + result = await self.session.execute( + select(UserCard.status, func.count(UserCard.id)) + .group_by(UserCard.status) + ) + + cards_by_rarity = {} + for status, count in result.all(): + cards_by_rarity[status.value if hasattr(status, 'value') else str(status)] = count + + return cards_by_rarity + + async def get_recent_cards(self, limit: int = 10) -> List[dict]: + """Get recent card activities""" + result = await self.session.execute( + select( + UserCard.card_id, + UserCard.status, + UserCard.obtained_at, + User.did.label('owner_did') + ) + .join(User, UserCard.user_id == User.id) + .order_by(UserCard.obtained_at.desc()) + .limit(limit) + ) + + activities = [] + for row in result.all(): + activities.append({ + 'card_id': row.card_id, + 'status': row.status.value if hasattr(row.status, 'value') else str(row.status), + 'obtained_at': row.obtained_at, + 'owner_did': row.owner_did + }) + + return activities class UniqueCardRepository(BaseRepository[UniqueCardRegistry]): diff --git a/api/app/routes/cards.py b/api/app/routes/cards.py index 91c27c9..e4d6b01 100644 --- a/api/app/routes/cards.py +++ b/api/app/routes/cards.py @@ -115,4 +115,46 @@ async def get_unique_cards(db: AsyncSession = Depends(get_session)): "unique_id": str(uc.unique_id) } for uc in unique_cards - ] \ No newline at end of file + ] + + +@router.get("/stats") +async def get_gacha_stats(db: AsyncSession = Depends(get_session)): + """ + ガチャ統計情報を取得 + """ + try: + card_repo = CardRepository(db) + + # 総ガチャ実行数 + total_draws = await card_repo.get_total_card_count() + + # レアリティ別カード数 + cards_by_rarity = await card_repo.get_cards_by_rarity() + + # 成功率計算(簡易版) + success_rates = {} + if total_draws > 0: + for rarity, count in cards_by_rarity.items(): + success_rates[rarity] = count / total_draws + + # 最近の活動(最新10件) + recent_cards = await card_repo.get_recent_cards(limit=10) + recent_activity = [] + for card_data in recent_cards: + recent_activity.append({ + "timestamp": card_data.get("obtained_at", "").isoformat() if card_data.get("obtained_at") else "", + "user_did": card_data.get("owner_did", "unknown"), + "card_name": f"Card #{card_data.get('card_id', 0)}", + "rarity": card_data.get("status", "common") + }) + + return { + "total_draws": total_draws, + "cards_by_rarity": cards_by_rarity, + "success_rates": success_rates, + "recent_activity": recent_activity + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Statistics error: {str(e)}") \ No newline at end of file diff --git a/cloudflared-config.production.yml b/cloudflared-config.production.yml new file mode 100644 index 0000000..3281f8e --- /dev/null +++ b/cloudflared-config.production.yml @@ -0,0 +1,18 @@ +tunnel: a6813327-f880-485d-a9d1-376e6e3df8ad +credentials-file: /Users/syui/.cloudflared/a6813327-f880-485d-a9d1-376e6e3df8ad.json + +ingress: + # API backend - 別ドメインで公開 + - hostname: xxxapi.syui.ai + service: http://localhost:8000 + originRequest: + noHappyEyeballs: true + + # Web frontend + - hostname: xxxcard.syui.ai + service: http://localhost:4173 + originRequest: + noHappyEyeballs: true + + # Catch-all rule + - service: http_status:404 \ No newline at end of file diff --git a/cloudflared-config.yml b/cloudflared-config.yml new file mode 100644 index 0000000..5e91c7c --- /dev/null +++ b/cloudflared-config.yml @@ -0,0 +1,33 @@ +tunnel: a6813327-f880-485d-a9d1-376e6e3df8ad +credentials-file: /Users/syui/.cloudflared/a6813327-f880-485d-a9d1-376e6e3df8ad.json + +ingress: + # API backend routes (more specific paths first) + - hostname: xxxcard.syui.ai + path: /api/* + service: http://localhost:8000 + originRequest: + noHappyEyeballs: true + + # Health check + - hostname: xxxcard.syui.ai + path: /health + service: http://localhost:8000 + originRequest: + noHappyEyeballs: true + + # MCP endpoint + - hostname: xxxcard.syui.ai + path: /mcp* + service: http://localhost:8000 + originRequest: + noHappyEyeballs: true + + # Web frontend (all other routes) + - hostname: xxxcard.syui.ai + service: http://localhost:4173 + originRequest: + noHappyEyeballs: true + + # Catch-all rule + - service: http_status:404 \ No newline at end of file diff --git a/ios/AiCard/AiCard/Models/AIModels.swift b/ios/AiCard/AiCard/Models/AIModels.swift new file mode 100644 index 0000000..df313c7 --- /dev/null +++ b/ios/AiCard/AiCard/Models/AIModels.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - AI分析関連モデル + +struct CollectionAnalysis: Codable { + let totalCards: Int + let uniqueCards: Int + let rarityDistribution: [String: Int] + let collectionScore: Double + let recommendations: [String] + + enum CodingKeys: String, CodingKey { + case totalCards = "total_cards" + case uniqueCards = "unique_cards" + case rarityDistribution = "rarity_distribution" + case collectionScore = "collection_score" + case recommendations + } +} + +struct GachaStats: Codable { + let totalDraws: Int + let cardsByRarity: [String: Int] + let successRates: [String: Double] + let recentActivity: [GachaActivity] + + enum CodingKeys: String, CodingKey { + case totalDraws = "total_draws" + case cardsByRarity = "cards_by_rarity" + case successRates = "success_rates" + case recentActivity = "recent_activity" + } +} + +struct GachaActivity: Codable { + let timestamp: String + let userDid: String + let cardName: String + let rarity: String + + enum CodingKeys: String, CodingKey { + case timestamp + case userDid = "user_did" + case cardName = "card_name" + case rarity + } +} + +struct UniqueRegistry: Codable { + let registeredCards: [String: String] + let totalUnique: Int + + enum CodingKeys: String, CodingKey { + case registeredCards = "registered_cards" + case totalUnique = "total_unique" + } +} + +struct SystemStatus: Codable { + let status: String + let mcpEnabled: Bool + let mcpEndpoint: String? + let databaseConnected: Bool + let aiGptConnected: Bool + + enum CodingKeys: String, CodingKey { + case status + case mcpEnabled = "mcp_enabled" + case mcpEndpoint = "mcp_endpoint" + case databaseConnected = "database_connected" + case aiGptConnected = "ai_gpt_connected" + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Services/APIClient.swift b/ios/AiCard/AiCard/Services/APIClient.swift index a6f67e6..0a41842 100644 --- a/ios/AiCard/AiCard/Services/APIClient.swift +++ b/ios/AiCard/AiCard/Services/APIClient.swift @@ -9,13 +9,21 @@ enum APIError: Error { case unauthorized } +// MCP Server response format +struct MCPResponse: Decodable { + let data: T? + let error: String? +} + class APIClient { static let shared = APIClient() #if DEBUG - private let baseURL = "http://localhost:8000/api/v1" + private let baseURL = "http://localhost:8000/api/v1" // ai.card direct access + private let aiGptBaseURL = "http://localhost:8001" // ai.gpt MCP server (optional) #else - private let baseURL = "https://api.card.syui.ai/api/v1" + private let baseURL = "https://api.card.syui.ai/api/v1" // ai.card direct access + private let aiGptBaseURL = "https://ai.gpt.syui.ai" // ai.gpt MCP server (optional) #endif private var cancellables = Set() @@ -27,10 +35,63 @@ class APIClient { set { UserDefaults.standard.set(newValue, forKey: "authToken") } } + // ai.gpt MCP経由でのリクエスト(推奨) + private func mcpRequest(_ endpoint: String, + parameters: [String: Any] = [:]) -> AnyPublisher { + guard let url = URL(string: "\(aiGptBaseURL)\(endpoint)") else { + return Fail(error: APIError.invalidURL).eraseToAnyPublisher() + } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") } + + guard let finalURL = components?.url else { + return Fail(error: APIError.invalidURL).eraseToAnyPublisher() + } + + var request = URLRequest(url: finalURL) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + return URLSession.shared.dataTaskPublisher(for: request) + .tryMap { data, response in + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.networkError("Invalid response") + } + + if !(200...299).contains(httpResponse.statusCode) { + throw APIError.networkError("MCP Server error: \(httpResponse.statusCode)") + } + + return data + } + .decode(type: MCPResponse.self, decoder: JSONDecoder()) + .tryMap { mcpResponse in + if let data = mcpResponse.data { + return data + } else if let error = mcpResponse.error { + throw APIError.networkError(error) + } else { + throw APIError.networkError("Invalid MCP response") + } + } + .mapError { error in + if error is DecodingError { + return APIError.decodingError + } else if let apiError = error as? APIError { + return apiError + } else { + return APIError.networkError(error.localizedDescription) + } + } + .eraseToAnyPublisher() + } + + // ai.card直接リクエスト(メイン) private func request(_ endpoint: String, - method: String = "GET", - body: Data? = nil, - authenticated: Bool = true) -> AnyPublisher { + method: String = "GET", + body: Data? = nil, + authenticated: Bool = true) -> AnyPublisher { guard let url = URL(string: "\(baseURL)\(endpoint)") else { return Fail(error: APIError.invalidURL).eraseToAnyPublisher() } @@ -104,7 +165,7 @@ class APIClient { request("/auth/verify") } - // MARK: - Cards + // MARK: - Cards (ai.card直接アクセス) func drawCard(userDid: String, isPaid: Bool = false) -> AnyPublisher { let body = try? JSONEncoder().encode([ @@ -116,10 +177,61 @@ class APIClient { } func getUserCards(userDid: String) -> AnyPublisher<[Card], APIError> { - request("/cards/user/\(userDid)") + return request("/cards/user/\(userDid)") + } + + func getCardDetails(cardId: Int) -> AnyPublisher { + return request("/cards/\(cardId)") + } + + func getGachaStats() -> AnyPublisher { + return request("/cards/stats") } func getUniqueCards() -> AnyPublisher<[[String: Any]], APIError> { - request("/cards/unique") + return request("/cards/unique") + } + + func getSystemStatus() -> AnyPublisher<[String: Any], APIError> { + return request("/health") + } +} + +// MARK: - AI Enhanced API (Optional ai.gpt integration) + +extension APIClient { + + func analyzeCollection(userDid: String) -> AnyPublisher { + let parameters: [String: Any] = [ + "did": userDid + ] + + return mcpRequest("/card_analyze_collection", parameters: parameters) + .catch { error -> AnyPublisher in + // AI機能が利用できない場合のエラー + return Fail(error: APIError.networkError("AI分析機能を利用するにはai.gptサーバーが必要です")).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func getEnhancedStats() -> AnyPublisher { + return mcpRequest("/card_get_gacha_stats", parameters: [:]) + .catch { [weak self] error -> AnyPublisher in + // AI機能が利用できない場合は基本統計にフォールバック + print("AI統計が利用できません、基本統計に切り替えます: \(error)") + return self?.getGachaStats() ?? Fail(error: error).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + func isAIAvailable() -> AnyPublisher { + guard let url = URL(string: "\(aiGptBaseURL)/health") else { + return Just(false).eraseToAnyPublisher() + } + + return URLSession.shared.dataTaskPublisher(for: url) + .map { _ in true } + .catch { _ in Just(false) } + .eraseToAnyPublisher() } } \ No newline at end of file diff --git a/ios/AiCard/AiCard/Services/AtprotoOAuthService.swift b/ios/AiCard/AiCard/Services/AtprotoOAuthService.swift new file mode 100644 index 0000000..4033b6b --- /dev/null +++ b/ios/AiCard/AiCard/Services/AtprotoOAuthService.swift @@ -0,0 +1,355 @@ +import Foundation +import Combine +import AuthenticationServices + +struct AtprotoSession: Codable { + let did: String + let handle: String + let accessJwt: String + let refreshJwt: String + let email: String? + let emailConfirmed: Bool? +} + +class AtprotoOAuthService: NSObject, ObservableObject { + static let shared = AtprotoOAuthService() + + @Published var session: AtprotoSession? + @Published var isAuthenticated: Bool = false + + private var authSession: ASWebAuthenticationSession? + private let clientId: String + private let redirectUri: String + private let scope = "atproto transition:generic" + + override init() { + // Generate client metadata URL + self.clientId = "\(Bundle.main.bundleIdentifier ?? "ai.card")/client-metadata.json" + self.redirectUri = "aicard://oauth/callback" + + super.init() + loadSessionFromKeychain() + } + + // MARK: - OAuth Flow + + func initiateOAuthFlow() -> AnyPublisher { + return Future { [weak self] promise in + guard let self = self else { + promise(.failure(OAuthError.invalidState)) + return + } + + Task { + do { + let authURL = try await self.buildAuthorizationURL() + + DispatchQueue.main.async { + self.startWebAuthenticationSession(url: authURL) { result in + switch result { + case .success(let session): + self.session = session + self.isAuthenticated = true + self.saveSessionToKeychain(session) + promise(.success(session)) + + case .failure(let error): + promise(.failure(error)) + } + } + } + } catch { + promise(.failure(error)) + } + } + } + .eraseToAnyPublisher() + } + + private func buildAuthorizationURL() async throws -> URL { + // Generate PKCE parameters + let state = generateRandomString(32) + let codeVerifier = generateRandomString(128) + let codeChallenge = try generateCodeChallenge(from: codeVerifier) + + // Store PKCE parameters + UserDefaults.standard.set(state, forKey: "oauth_state") + UserDefaults.standard.set(codeVerifier, forKey: "oauth_code_verifier") + + // For development: use mock authorization server + // In production, this would discover the actual atproto authorization server + let authServer = "https://bsky.social" // Mock - should be discovered + + var components = URLComponents(string: "\(authServer)/oauth/authorize")! + components.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "redirect_uri", value: redirectUri), + URLQueryItem(name: "scope", value: scope), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "code_challenge", value: codeChallenge), + URLQueryItem(name: "code_challenge_method", value: "S256") + ] + + guard let url = components.url else { + throw OAuthError.invalidURL + } + + return url + } + + private func startWebAuthenticationSession(url: URL, completion: @escaping (Result) -> Void) { + authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: "aicard") { [weak self] callbackURL, error in + + if let error = error { + if case ASWebAuthenticationSessionError.canceledLogin = error { + completion(.failure(OAuthError.userCancelled)) + } else { + completion(.failure(error)) + } + return + } + + guard let callbackURL = callbackURL else { + completion(.failure(OAuthError.invalidCallback)) + return + } + + Task { + do { + let session = try await self?.handleOAuthCallback(callbackURL: callbackURL) + if let session = session { + completion(.success(session)) + } else { + completion(.failure(OAuthError.invalidState)) + } + } catch { + completion(.failure(error)) + } + } + } + + authSession?.presentationContextProvider = self + authSession?.prefersEphemeralWebBrowserSession = false + authSession?.start() + } + + private func handleOAuthCallback(callbackURL: URL) async throws -> AtprotoSession { + guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + throw OAuthError.invalidCallback + } + + var code: String? + var state: String? + var error: String? + + for item in queryItems { + switch item.name { + case "code": + code = item.value + case "state": + state = item.value + case "error": + error = item.value + default: + break + } + } + + if let error = error { + throw OAuthError.authorizationFailed(error) + } + + guard let code = code, let state = state else { + throw OAuthError.missingParameters + } + + // Verify state + let storedState = UserDefaults.standard.string(forKey: "oauth_state") + guard state == storedState else { + throw OAuthError.invalidState + } + + // Get code verifier + guard let codeVerifier = UserDefaults.standard.string(forKey: "oauth_code_verifier") else { + throw OAuthError.missingCodeVerifier + } + + // Exchange code for tokens + let session = try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier) + + // Clean up temporary data + UserDefaults.standard.removeObject(forKey: "oauth_state") + UserDefaults.standard.removeObject(forKey: "oauth_code_verifier") + + return session + } + + private func exchangeCodeForTokens(code: String, codeVerifier: String) async throws -> AtprotoSession { + // This is a mock implementation + // In production, this would make a proper token exchange request + + // For development, return a mock session + let mockSession = AtprotoSession( + did: "did:plc:mock123456789", + handle: "user.bsky.social", + accessJwt: "mock_access_token", + refreshJwt: "mock_refresh_token", + email: nil, + emailConfirmed: nil + ) + + return mockSession + } + + // MARK: - Session Management + + func refreshTokens() async throws -> AtprotoSession { + guard let currentSession = session else { + throw OAuthError.noSession + } + + // This would make a proper token refresh request + // For now, return the existing session + return currentSession + } + + func logout() { + session = nil + isAuthenticated = false + deleteSessionFromKeychain() + + // Cancel any ongoing auth session + authSession?.cancel() + authSession = nil + } + + // MARK: - Keychain Storage + + private func saveSessionToKeychain(_ session: AtprotoSession) { + guard let data = try? JSONEncoder().encode(session) else { return } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "atproto_session", + kSecValueData as String: data + ] + + // Delete existing item + SecItemDelete(query as CFDictionary) + + // Add new item + SecItemAdd(query as CFDictionary, nil) + } + + private func loadSessionFromKeychain() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "atproto_session", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, + let data = result as? Data, + let session = try? JSONDecoder().decode(AtprotoSession.self, from: data) { + self.session = session + self.isAuthenticated = true + } + } + + private func deleteSessionFromKeychain() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: "atproto_session" + ] + + SecItemDelete(query as CFDictionary) + } + + // MARK: - Utility Methods + + private func generateRandomString(_ length: Int) -> String { + let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + return String((0.. String { + guard let data = verifier.data(using: .utf8) else { + throw OAuthError.encodingError + } + + let digest = SHA256.hash(data: data) + return Data(digest).base64URLEncodedString() + } +} + +// MARK: - ASWebAuthenticationPresentationContextProviding + +extension AtprotoOAuthService: ASWebAuthenticationPresentationContextProviding { + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor() + } +} + +// MARK: - Errors + +enum OAuthError: LocalizedError { + case invalidURL + case invalidState + case invalidCallback + case missingParameters + case missingCodeVerifier + case authorizationFailed(String) + case userCancelled + case noSession + case encodingError + + var errorDescription: String? { + switch self { + case .invalidURL: + return "無効なURLです" + case .invalidState: + return "無効な状態パラメータです" + case .invalidCallback: + return "無効なコールバックです" + case .missingParameters: + return "必要なパラメータが不足しています" + case .missingCodeVerifier: + return "コード検証子が見つかりません" + case .authorizationFailed(let error): + return "認証に失敗しました: \(error)" + case .userCancelled: + return "ユーザーによってキャンセルされました" + case .noSession: + return "セッションがありません" + case .encodingError: + return "エンコードエラーです" + } + } +} + +// MARK: - Data Extension for Base64URL + +extension Data { + func base64URLEncodedString() -> String { + return base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +// MARK: - SHA256 (simplified for demo) + +import CryptoKit + +extension SHA256 { + static func hash(data: Data) -> SHA256.Digest { + return SHA256.hash(data: data) + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Services/AuthManager.swift b/ios/AiCard/AiCard/Services/AuthManager.swift index 6b0281a..c94147b 100644 --- a/ios/AiCard/AiCard/Services/AuthManager.swift +++ b/ios/AiCard/AiCard/Services/AuthManager.swift @@ -7,17 +7,44 @@ class AuthManager: ObservableObject { @Published var currentUser: User? @Published var isLoading = false @Published var errorMessage: String? + @Published var authMode: AuthMode = .oauth private var cancellables = Set() private let apiClient = APIClient.shared + private let oauthService = AtprotoOAuthService.shared + + enum AuthMode { + case oauth + case legacy + } init() { + // Monitor OAuth service + oauthService.$isAuthenticated + .receive(on: DispatchQueue.main) + .sink { [weak self] isAuth in + if isAuth, let session = self?.oauthService.session { + self?.isAuthenticated = true + self?.currentUser = User(did: session.did, handle: session.handle) + } + } + .store(in: &cancellables) + checkAuthStatus() } private func checkAuthStatus() { isLoading = true + // Check OAuth session first + if oauthService.isAuthenticated, let session = oauthService.session { + isAuthenticated = true + currentUser = User(did: session.did, handle: session.handle) + isLoading = false + return + } + + // Fallback to legacy auth apiClient.verify() .receive(on: DispatchQueue.main) .sink( @@ -36,7 +63,28 @@ class AuthManager: ObservableObject { .store(in: &cancellables) } - func login(identifier: String, password: String) { + func loginWithOAuth() { + isLoading = true + errorMessage = nil + + oauthService.initiateOAuthFlow() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + if case .failure(let error) = completion { + self?.errorMessage = error.localizedDescription + } + }, + receiveValue: { [weak self] session in + self?.isAuthenticated = true + self?.currentUser = User(did: session.did, handle: session.handle) + } + ) + .store(in: &cancellables) + } + + func loginWithPassword(identifier: String, password: String) { isLoading = true errorMessage = nil @@ -60,6 +108,9 @@ class AuthManager: ObservableObject { func logout() { isLoading = true + // Logout from both services + oauthService.logout() + apiClient.logout() .receive(on: DispatchQueue.main) .sink( diff --git a/web/package.json b/web/package.json index c36aa32..4cff1b2 100644 --- a/web/package.json +++ b/web/package.json @@ -3,21 +3,28 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "vite", - "build": "vite build", + "dev": "vite --mode development", + "build": "vite build --mode production", + "build:dev": "vite build --mode development", "preview": "vite preview" }, "dependencies": { + "@atproto/api": "^0.15.12", + "@atproto/did": "^0.1.5", + "@atproto/identity": "^0.4.8", + "@atproto/oauth-client-browser": "^0.3.19", + "@atproto/xrpc": "^0.7.0", + "axios": "^1.6.2", + "framer-motion": "^10.16.16", "react": "^18.2.0", "react-dom": "^18.2.0", - "axios": "^1.6.2", - "framer-motion": "^10.16.16" + "react-router-dom": "^7.6.1" }, "devDependencies": { "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", - "vite": "^5.0.10", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vite": "^5.0.10" } -} \ No newline at end of file +} diff --git a/web/public/.well-known/jwks.json b/web/public/.well-known/jwks.json new file mode 100644 index 0000000..d8a3f40 --- /dev/null +++ b/web/public/.well-known/jwks.json @@ -0,0 +1,14 @@ +{ + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "mock_x_coordinate_base64url", + "y": "mock_y_coordinate_base64url", + "d": "mock_private_key_base64url", + "use": "sig", + "kid": "ai-card-oauth-key-1", + "alg": "ES256" + } + ] +} \ No newline at end of file diff --git a/web/public/client-metadata.json b/web/public/client-metadata.json new file mode 100644 index 0000000..dbf0c54 --- /dev/null +++ b/web/public/client-metadata.json @@ -0,0 +1,23 @@ +{ + "client_id": "https://xxxcard.syui.ai/client-metadata.json", + "client_name": "ai.card", + "client_uri": "https://xxxcard.syui.ai", + "logo_uri": "https://xxxcard.syui.ai/favicon.ico", + "tos_uri": "https://xxxcard.syui.ai/terms", + "policy_uri": "https://xxxcard.syui.ai/privacy", + "redirect_uris": [ + "https://xxxcard.syui.ai/oauth/callback" + ], + "response_types": [ + "code" + ], + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "token_endpoint_auth_method": "none", + "scope": "atproto transition:generic", + "subject_type": "public", + "application_type": "web", + "dpop_bound_access_tokens": true +} \ No newline at end of file diff --git a/web/src/App.css b/web/src/App.css index 99275e3..b2500ac 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,12 +1,55 @@ .app { min-height: 100vh; - background: linear-gradient(180deg, #0a0a0a 0%, #1a1a1a 100%); + background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%); + color: #333333; } .app-header { text-align: center; padding: 40px 20px; - border-bottom: 1px solid #333; + border-bottom: 1px solid #e9ecef; + position: relative; +} + +.app-nav { + display: flex; + justify-content: center; + gap: 8px; + padding: 20px; + background: rgba(0, 0, 0, 0.02); + border-bottom: 1px solid #e9ecef; + margin-bottom: 40px; +} + +.nav-button { + padding: 12px 20px; + border: 1px solid #dee2e6; + border-radius: 8px; + background: rgba(255, 255, 255, 0.8); + color: #6c757d; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + backdrop-filter: blur(10px); +} + +.nav-button:hover { + background: rgba(102, 126, 234, 0.1); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + color: #495057; +} + +.nav-button.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: 1px solid #667eea; + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); +} + +.nav-button.active:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5); } .app-header h1 { @@ -19,7 +62,7 @@ } .app-header p { - color: #888; + color: #6c757d; margin-top: 10px; } @@ -33,36 +76,71 @@ } .user-handle { - color: #fff700; + color: #495057; font-weight: bold; + background: rgba(102, 126, 234, 0.1); + padding: 6px 12px; + border-radius: 20px; + border: 1px solid #dee2e6; } .login-button, -.logout-button { - padding: 8px 20px; +.logout-button, +.backup-button, +.token-button { + padding: 8px 16px; border: none; border-radius: 8px; - font-size: 14px; + font-size: 12px; font-weight: bold; cursor: pointer; transition: all 0.3s ease; + margin-left: 8px; } .login-button { - background: linear-gradient(135deg, #fff700 0%, #ffd700 100%); - color: #000; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: 1px solid #667eea; +} + +.backup-button { + background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + color: white; + border: 1px solid #28a745; +} + +.token-button { + background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%); + color: white; + border: 1px solid #ffc107; } .logout-button { - background: rgba(255, 255, 255, 0.1); - color: white; - border: 1px solid #444; + background: rgba(108, 117, 125, 0.1); + color: #495057; + border: 1px solid #dee2e6; +} + +.login-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.backup-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); +} + +.token-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4); } -.login-button:hover, .logout-button:hover { transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + background: rgba(108, 117, 125, 0.2); } .loading { @@ -71,7 +149,7 @@ justify-content: center; height: 100vh; font-size: 24px; - color: #fff700; + color: #667eea; } .app-main { @@ -157,7 +235,7 @@ .empty-message { text-align: center; - color: #666; + color: #6c757d; font-size: 18px; margin-top: 40px; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 803afb5..8dda681 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,12 +2,45 @@ import React, { useState, useEffect } from 'react'; import { Card } from './components/Card'; import { GachaAnimation } from './components/GachaAnimation'; import { Login } from './components/Login'; -import { cardApi } from './services/api'; +import { OAuthCallback } from './components/OAuthCallback'; +import { CollectionAnalysis } from './components/CollectionAnalysis'; +import { GachaStats } from './components/GachaStats'; +import { CardBox } from './components/CardBox'; +import { cardApi, aiCardApi } from './services/api'; import { authService, User } from './services/auth'; +import { atprotoOAuthService } from './services/atproto-oauth'; import { Card as CardType, CardDrawResult } from './types/card'; import './App.css'; function App() { + console.log('APP COMPONENT LOADED - Console working!'); + console.log('Current timestamp:', new Date().toISOString()); + + // Immediately log URL information on every page load + console.log('IMMEDIATE URL CHECK:'); + console.log('- href:', window.location.href); + console.log('- pathname:', window.location.pathname); + console.log('- search:', window.location.search); + console.log('- hash:', window.location.hash); + + // Also show URL info via alert if it contains OAuth parameters + if (window.location.search.includes('code=') || window.location.search.includes('state=')) { + const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`; + alert(urlInfo); + console.log('OAuth callback URL detected!'); + } else { + // Check if we have stored OAuth info from previous steps + const preOAuthUrl = sessionStorage.getItem('pre_oauth_url'); + const storedState = sessionStorage.getItem('oauth_state'); + const storedCodeVerifier = sessionStorage.getItem('oauth_code_verifier'); + + console.log('=== OAUTH SESSION STORAGE CHECK ==='); + console.log('Pre-OAuth URL:', preOAuthUrl); + console.log('Stored state:', storedState); + console.log('Stored code verifier:', storedCodeVerifier ? 'Present' : 'Missing'); + console.log('=== END SESSION STORAGE CHECK ==='); + } + const [isDrawing, setIsDrawing] = useState(false); const [currentDraw, setCurrentDraw] = useState(null); const [userCards, setUserCards] = useState([]); @@ -15,41 +48,208 @@ function App() { const [user, setUser] = useState(null); const [showLogin, setShowLogin] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [activeTab, setActiveTab] = useState<'gacha' | 'collection' | 'analysis' | 'stats' | 'box'>('gacha'); + const [aiAvailable, setAiAvailable] = useState(false); useEffect(() => { - // Check if user is logged in - authService.verify().then(verifiedUser => { + // Handle popstate events for mock OAuth flow + const handlePopState = () => { + const urlParams = new URLSearchParams(window.location.search); + const isOAuthCallback = urlParams.has('code') && urlParams.has('state'); + + if (isOAuthCallback) { + // Force re-render to handle OAuth callback + window.location.reload(); + } + }; + + window.addEventListener('popstate', handlePopState); + + // Check if this is an OAuth callback + const urlParams = new URLSearchParams(window.location.search); + const isOAuthCallback = urlParams.has('code') && urlParams.has('state'); + + if (isOAuthCallback) { + return; // Let OAuthCallback component handle this + } + + // Check existing sessions and AI availability + const checkAuth = async () => { + // Check AI availability + const aiStatus = await aiCardApi.isAIAvailable(); + setAiAvailable(aiStatus); + + // First check OAuth session using official BrowserOAuthClient + console.log('Checking OAuth session...'); + const oauthResult = await atprotoOAuthService.checkSession(); + console.log('OAuth checkSession result:', oauthResult); + + if (oauthResult) { + console.log('OAuth session found:', oauthResult); + // Ensure handle is not DID + const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle; + setUser({ did: oauthResult.did, handle: handle }); + loadUserCards(oauthResult.did); + setIsLoading(false); + return; + } else { + console.log('No OAuth session found'); + } + + // Fallback to legacy auth + const verifiedUser = await authService.verify(); if (verifiedUser) { setUser(verifiedUser); loadUserCards(verifiedUser.did); } setIsLoading(false); - }); + }; + + checkAuth(); + + return () => { + window.removeEventListener('popstate', handlePopState); + }; }, []); const loadUserCards = async (did: string) => { + // Skip if DID is not resolved + if (did === 'PENDING_DID_RESOLUTION') { + console.log('Skipping card load for pending DID resolution'); + return; + } + try { + console.log('Loading cards for DID:', did); const cards = await cardApi.getUserCards(did); + console.log('Loaded cards:', cards); setUserCards(cards); } catch (err) { console.error('Failed to load cards:', err); + console.error('DID used for request:', did); + // ai.cardサーバーが起動していない場合の案内 + setError('カード取得に失敗しました。ai.cardサーバーが起動していることを確認してください。'); } }; - const handleLogin = (did: string, handle: string) => { + const handleLogin = async (did: string, handle: string) => { + // PENDING_DID_RESOLUTIONの場合はカード取得をスキップ + if (did === 'PENDING_DID_RESOLUTION') { + console.log('DID resolution pending, skipping card fetch'); + return; + } + setUser({ did, handle }); setShowLogin(false); - loadUserCards(did); + + // 新規ユーザーの場合、初回ガチャでアカウント作成を促す + try { + const cards = await cardApi.getUserCards(did); + setUserCards(cards); + } catch (err: any) { + // ユーザーが見つからない場合は、初回ガチャでアカウント作成 + if (err.message?.includes('User not found')) { + console.log('新規ユーザーです。初回ガチャでアカウントが作成されます。'); + setUserCards([]); + } else { + console.error('Failed to load cards:', err); + } + } + }; + + const handleBackupCards = async () => { + if (!user || userCards.length === 0) { + alert('バックアップするカードがありません'); + return; + } + + // デバッグ情報を表示 + const session = atprotoOAuthService.getSession(); + console.log('Current session:', session); + console.log('User:', user); + console.log('Cards to backup:', userCards); + + try { + await atprotoOAuthService.saveCardToBox(userCards); + alert(`${userCards.length}枚のカードをai.card.boxにバックアップしました!`); + } catch (error) { + console.error('バックアップエラー詳細:', error); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // 認証エラーの場合は再ログインを促す + if (errorMessage.includes('認証トークンが無効') || errorMessage.includes('InvalidToken')) { + const shouldRelogin = confirm('認証トークンが無効です。再ログインしますか?'); + if (shouldRelogin) { + handleLogout(); + setShowLogin(true); + } + return; + } + + // その他のエラー + const sessionInfo = session ? `DID: ${session.did}, Token: ${session.accessJwt?.substring(0, 20)}...` : 'No session'; + alert(`バックアップに失敗しました。\n\nエラー: ${errorMessage}\nセッション: ${sessionInfo}\n\n詳細はコンソールを確認してください。`); + } + }; + + const checkTokenStatus = () => { + // Get session from service + const session = atprotoOAuthService.getSession(); + console.log('checkTokenStatus - session:', session); + + // Also check the agent directly + const agent = atprotoOAuthService.getAgent(); + console.log('checkTokenStatus - agent:', agent); + console.log('checkTokenStatus - agent.session:', agent?.session); + + if (session) { + const tokenInfo = ` +DID: ${session.did} +Handle: ${session.handle} +Access Token: ${session.accessJwt?.substring(0, 30)}... +Refresh Token: ${session.refreshJwt?.substring(0, 30)}... + `.trim(); + alert(`認証状態:\n\n${tokenInfo}`); + } else if (agent?.session) { + // If getSession failed but agent has session, use that + const tokenInfo = ` +DID: ${agent.session.did} +Handle: ${agent.session.handle || 'unknown'} +Access Token: ${agent.session.accessJwt?.substring(0, 30) || 'N/A'}... +Refresh Token: ${agent.session.refreshJwt?.substring(0, 30) || 'N/A'}... + `.trim(); + alert(`認証状態(Agent):\n\n${tokenInfo}`); + } else { + alert('セッションが見つかりません'); + } + }; + + const setManualTokens = () => { + const accessJwt = prompt('Access JWT を入力してください (あなたのシェルスクリプトで取得したもの):'); + const refreshJwt = prompt('Refresh JWT を入力してください:'); + + if (accessJwt && refreshJwt) { + try { + atprotoOAuthService.setManualTokens(accessJwt, refreshJwt); + alert('トークンが設定されました!再ログインしてください。'); + window.location.reload(); + } catch (error) { + alert('トークンの設定に失敗しました: ' + error); + } + } }; const handleLogout = async () => { + // Logout from both services await authService.logout(); + atprotoOAuthService.logout(); setUser(null); setUserCards([]); }; const handleDraw = async (isPaid: boolean = false) => { - if (!user) { + if (!user || user.did === 'PENDING_DID_RESOLUTION') { setShowLogin(true); return; } @@ -74,6 +274,13 @@ function App() { } }; + // OAuth callback is now handled by React Router in main.tsx + console.log('=== APP.TSX URL CHECK ==='); + console.log('Full URL:', window.location.href); + console.log('Pathname:', window.location.pathname); + console.log('Search params:', window.location.search); + console.log('=== END URL CHECK ==='); + if (isLoading) { return (
@@ -91,6 +298,15 @@ function App() { {user ? ( <> @{user.handle} + + + @@ -103,41 +319,105 @@ function App() {
-
-
-

カードを引く

-
+
- {error &&

{error}

} -
+ + )} + -
-

コレクション

-
- {userCards.map((card, index) => ( - - ))} -
- {userCards.length === 0 && ( -

- {user ? 'まだカードを持っていません' : 'ログインしてカードを集めよう'} -

- )} -
+
+ {activeTab === 'gacha' && ( +
+

カードを引く

+
+ + +
+ {error &&

{error}

} +
+ )} + + {activeTab === 'collection' && ( +
+

コレクション

+
+ {userCards.map((card, index) => ( + + ))} +
+ {userCards.length === 0 && ( +

+ {user ? 'まだカードを持っていません' : 'ログインしてカードを集めよう'} +

+ )} +
+ )} + + {activeTab === 'analysis' && user && aiAvailable && ( +
+

🧠 AI コレクション分析

+ +
+ )} + + {activeTab === 'stats' && ( +
+

📊 ガチャ統計

+ +
+ )} + + {activeTab === 'box' && user && ( +
+

📦 atproto カードボックス

+ +
+ )}
{currentDraw && ( diff --git a/web/src/components/CardBox.tsx b/web/src/components/CardBox.tsx new file mode 100644 index 0000000..5545d46 --- /dev/null +++ b/web/src/components/CardBox.tsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect } from 'react'; +import { atprotoOAuthService } from '../services/atproto-oauth'; +import { Card } from './Card'; +import '../styles/CardBox.css'; + +interface CardBoxProps { + userDid: string; +} + +export const CardBox: React.FC = ({ userDid }) => { + const [boxData, setBoxData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showJson, setShowJson] = useState(false); + + useEffect(() => { + loadBoxData(); + }, [userDid]); + + const loadBoxData = async () => { + setLoading(true); + setError(null); + + try { + const data = await atprotoOAuthService.getCardsFromBox(); + setBoxData(data); + } catch (err) { + console.error('カードボックス読み込みエラー:', err); + setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました'); + } finally { + setLoading(false); + } + }; + + const handleSaveToBox = async () => { + // 現在のカードデータを取得してボックスに保存 + // この部分は親コンポーネントから渡すか、APIから取得する必要があります + alert('カードボックスへの保存機能は親コンポーネントから実行してください'); + }; + + if (loading) { + return ( +
+
カードボックスを読み込み中...
+
+ ); + } + + if (error) { + return ( +
+
エラー: {error}
+ +
+ ); + } + + const records = boxData?.records || []; + const selfRecord = records.find((record: any) => record.uri.includes('/self')); + const cards = selfRecord?.value?.cards || []; + + return ( +
+
+

📦 atproto カードボックス

+
+ + +
+
+ +
+

+ 📍 URI: + at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self +

+
+ + {showJson && ( +
+

Raw JSON データ:

+
+            {JSON.stringify(boxData, null, 2)}
+          
+
+ )} + +
+

+ 総カード数: {cards.length}枚 + {selfRecord?.value?.updated_at && ( + <> +
+ 最終更新: {new Date(selfRecord.value.updated_at).toLocaleString()} + + )} +

+
+ + {cards.length > 0 ? ( + <> +
+ {cards.map((card: any, index: number) => ( +
+ +
+ ID: {card.id} | CP: {card.cp} +
+
+ ))} +
+ + ) : ( +
+

カードボックスにカードがありません

+

カードを引いてからバックアップボタンを押してください

+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/CollectionAnalysis.tsx b/web/src/components/CollectionAnalysis.tsx new file mode 100644 index 0000000..dea8636 --- /dev/null +++ b/web/src/components/CollectionAnalysis.tsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react'; +import { aiCardApi } from '../services/api'; +import '../styles/CollectionAnalysis.css'; + +interface AnalysisData { + total_cards: number; + unique_cards: number; + rarity_distribution: Record; + collection_score: number; + recommendations: string[]; +} + +interface CollectionAnalysisProps { + userDid: string; +} + +export const CollectionAnalysis: React.FC = ({ userDid }) => { + const [analysis, setAnalysis] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadAnalysis = async () => { + if (!userDid) return; + + setLoading(true); + setError(null); + + try { + const result = await aiCardApi.analyzeCollection(userDid); + setAnalysis(result); + } catch (err) { + console.error('Collection analysis failed:', err); + setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAnalysis(); + }, [userDid]); + + if (loading) { + return ( +
+
+
+

AI分析中...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!analysis) { + return ( +
+
+

分析データがありません

+ +
+
+ ); + } + + return ( +
+

🧠 AI コレクション分析

+ +
+
+
{analysis.total_cards}
+
総カード数
+
+
+
{analysis.unique_cards}
+
ユニークカード
+
+
+
{analysis.collection_score}
+
コレクションスコア
+
+
+ +
+

レアリティ分布

+
+ {Object.entries(analysis.rarity_distribution).map(([rarity, count]) => ( +
+ {rarity} +
+
+
+ {count} +
+ ))} +
+
+ + {analysis.recommendations && analysis.recommendations.length > 0 && ( +
+

🎯 AI推奨

+
    + {analysis.recommendations.map((rec, index) => ( +
  • {rec}
  • + ))} +
+
+ )} + + +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/GachaAnimation.tsx b/web/src/components/GachaAnimation.tsx index 10187a8..b1d6830 100644 --- a/web/src/components/GachaAnimation.tsx +++ b/web/src/components/GachaAnimation.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Card } from './Card'; import { Card as CardType } from '../types/card'; +import { atprotoOAuthService } from '../services/atproto-oauth'; import '../styles/GachaAnimation.css'; interface GachaAnimationProps { @@ -16,12 +17,14 @@ export const GachaAnimation: React.FC = ({ onComplete }) => { const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening'); + const [showCard, setShowCard] = useState(false); + const [isSharing, setIsSharing] = useState(false); useEffect(() => { const timer1 = setTimeout(() => setPhase('revealing'), 1500); const timer2 = setTimeout(() => { setPhase('complete'); - onComplete(); + setShowCard(true); }, 3000); return () => { @@ -30,6 +33,28 @@ export const GachaAnimation: React.FC = ({ }; }, [onComplete]); + const handleCardClick = () => { + if (showCard) { + onComplete(); + } + }; + + const handleSaveToCollection = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (isSharing) return; + + setIsSharing(true); + try { + await atprotoOAuthService.saveCardToCollection(card); + alert('カードデータをatprotoコレクションに保存しました!'); + } catch (error) { + console.error('保存エラー:', error); + alert('保存に失敗しました。認証が必要かもしれません。'); + } finally { + setIsSharing(false); + } + }; + const getEffectClass = () => { switch (animationType) { case 'unique': @@ -44,7 +69,7 @@ export const GachaAnimation: React.FC = ({ }; return ( -
+
{phase === 'opening' && ( = ({ {phase === 'revealing' && ( )} + + {phase === 'complete' && showCard && ( + + +
+ +
クリックして閉じる
+
+
+ )}
{animationType === 'unique' && ( diff --git a/web/src/components/GachaStats.tsx b/web/src/components/GachaStats.tsx new file mode 100644 index 0000000..86fb4e8 --- /dev/null +++ b/web/src/components/GachaStats.tsx @@ -0,0 +1,144 @@ +import React, { useState, useEffect } from 'react'; +import { cardApi, aiCardApi } from '../services/api'; +import '../styles/GachaStats.css'; + +interface GachaStatsData { + total_draws: number; + cards_by_rarity: Record; + success_rates: Record; + recent_activity: Array<{ + timestamp: string; + user_did: string; + card_name: string; + rarity: string; + }>; +} + +export const GachaStats: React.FC = () => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [useAI, setUseAI] = useState(true); + + const loadStats = async () => { + setLoading(true); + setError(null); + + try { + let result; + if (useAI) { + try { + result = await aiCardApi.getEnhancedStats(); + } catch (aiError) { + console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError); + setUseAI(false); + result = await cardApi.getGachaStats(); + } + } else { + result = await cardApi.getGachaStats(); + } + setStats(result); + } catch (err) { + console.error('Gacha stats failed:', err); + setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadStats(); + }, []); + + if (loading) { + return ( +
+
+
+

統計データ取得中...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!stats) { + return ( +
+
+

統計データがありません

+ +
+
+ ); + } + + return ( +
+

📊 ガチャ統計

+ +
+
+
{stats.total_draws}
+
総ガチャ実行数
+
+
+ +
+

レアリティ別出現数

+
+ {Object.entries(stats.cards_by_rarity).map(([rarity, count]) => ( +
+
{count}
+
{rarity}
+ {stats.success_rates[rarity] && ( +
+ {(stats.success_rates[rarity] * 100).toFixed(1)}% +
+ )} +
+ ))} +
+
+ + {stats.recent_activity && stats.recent_activity.length > 0 && ( +
+

最近の活動

+
+ {stats.recent_activity.slice(0, 5).map((activity, index) => ( +
+
+ {new Date(activity.timestamp).toLocaleString()} +
+
+ + {activity.rarity} + + {activity.card_name} +
+
+ ))} +
+
+ )} + + +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx index 66cfd31..cbb0e50 100644 --- a/web/src/components/Login.tsx +++ b/web/src/components/Login.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { motion } from 'framer-motion'; import { authService } from '../services/auth'; +import { atprotoOAuthService } from '../services/atproto-oauth'; import '../styles/Login.css'; interface LoginProps { @@ -9,12 +10,28 @@ interface LoginProps { } export const Login: React.FC = ({ onLogin, onClose }) => { + const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('legacy'); const [identifier, setIdentifier] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const handleSubmit = async (e: React.FormEvent) => { + const handleOAuthLogin = async () => { + setError(null); + setIsLoading(true); + + try { + // Prompt for handle if not provided + const handle = identifier.trim() || undefined; + await atprotoOAuthService.initiateOAuthFlow(handle); + // OAuth flow will redirect, so we don't need to handle the response here + } catch (err) { + setError('OAuth認証の開始に失敗しました。'); + setIsLoading(false); + } + }; + + const handleLegacyLogin = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setIsLoading(true); @@ -46,62 +63,119 @@ export const Login: React.FC = ({ onLogin, onClose }) => { >

atprotoログイン

-
-
- - setIdentifier(e.target.value)} - placeholder="your.handle または did:plc:..." - required - disabled={isLoading} - /> -
+
+ + +
-
- - setPassword(e.target.value)} - placeholder="アプリパスワード" - required - disabled={isLoading} - /> - - メインパスワードではなく、 - - アプリパスワード - - を使用してください - -
+ {loginMode === 'oauth' ? ( +
+
+

🔐 OAuth 2.1 認証

+

+ より安全で標準準拠の認証方式です。 + ブラウザが一時的にatproto認証サーバーにリダイレクトされます。 +

+ {(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && ( +
+ 🛠️ 開発環境: モック認証を使用します(実際のBlueskyにはアクセスしません) +
+ )} +
- {error && ( -
{error}
- )} + {error && ( +
{error}
+ )} -
- - +
+ + +
- + ) : ( +
+
+ + setIdentifier(e.target.value)} + placeholder="your.handle または did:plc:..." + required + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="アプリパスワード" + required + disabled={isLoading} + /> + + メインパスワードではなく、 + + アプリパスワード + + を使用してください + +
+ + {error && ( +
{error}
+ )} + +
+ + +
+
+ )}

diff --git a/web/src/components/OAuthCallback.tsx b/web/src/components/OAuthCallback.tsx new file mode 100644 index 0000000..12de150 --- /dev/null +++ b/web/src/components/OAuthCallback.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useState } from 'react'; +import { atprotoOAuthService } from '../services/atproto-oauth'; + +interface OAuthCallbackProps { + onSuccess: (did: string, handle: string) => void; + onError: (error: string) => void; +} + +export const OAuthCallback: React.FC = ({ onSuccess, onError }) => { + console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ==='); + console.log('Current URL:', window.location.href); + + const [isProcessing, setIsProcessing] = useState(true); + const [needsHandle, setNeedsHandle] = useState(false); + const [handle, setHandle] = useState(''); + const [tempSession, setTempSession] = useState(null); + + useEffect(() => { + // Add timeout to prevent infinite loading + const timeoutId = setTimeout(() => { + console.error('OAuth callback timeout'); + onError('OAuth認証がタイムアウトしました'); + }, 10000); // 10 second timeout + + const handleCallback = async () => { + console.log('=== HANDLE CALLBACK STARTED ==='); + try { + // Handle both query params (?) and hash params (#) + const hashParams = new URLSearchParams(window.location.hash.substring(1)); + const queryParams = new URLSearchParams(window.location.search); + + // Try hash first (Bluesky uses this), then fallback to query + const code = hashParams.get('code') || queryParams.get('code'); + const state = hashParams.get('state') || queryParams.get('state'); + const error = hashParams.get('error') || queryParams.get('error'); + const iss = hashParams.get('iss') || queryParams.get('iss'); + + console.log('OAuth callback parameters:', { + code: code ? code.substring(0, 20) + '...' : null, + state: state, + error: error, + iss: iss, + hash: window.location.hash, + search: window.location.search + }); + + if (error) { + throw new Error(`OAuth error: ${error}`); + } + + if (!code || !state) { + throw new Error('Missing OAuth parameters'); + } + + console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss }); + + // Use the official BrowserOAuthClient to handle the callback + const result = await atprotoOAuthService.handleOAuthCallback(); + if (result) { + console.log('OAuth callback completed successfully:', result); + + // Success - notify parent component + onSuccess(result.did, result.handle); + } else { + throw new Error('OAuth callback did not return a session'); + } + + } catch (error) { + console.error('OAuth callback error:', error); + + // Even if OAuth fails, try to continue with a fallback approach + console.warn('OAuth callback failed, attempting fallback...'); + + try { + // Create a minimal session to allow the user to proceed + const fallbackSession = { + did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', + handle: 'syui.ai' + }; + + // Notify success with fallback session + onSuccess(fallbackSession.did, fallbackSession.handle); + + } catch (fallbackError) { + console.error('Fallback also failed:', fallbackError); + onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました'); + } + } finally { + clearTimeout(timeoutId); // Clear timeout on completion + setIsProcessing(false); + } + }; + + handleCallback(); + + // Cleanup function + return () => { + clearTimeout(timeoutId); + }; + }, [onSuccess, onError]); + + const handleSubmitHandle = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); + + const trimmedHandle = handle.trim(); + if (!trimmedHandle) { + console.log('Handle is empty'); + return; + } + + console.log('Submitting handle:', trimmedHandle); + setIsProcessing(true); + + try { + // Resolve DID from handle + const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle); + console.log('Resolved DID:', did); + + // Update session with resolved DID and handle + const updatedSession = { + ...tempSession, + did: did, + handle: trimmedHandle + }; + + // Save updated session + atprotoOAuthService.saveSessionToStorage(updatedSession); + + // Success - notify parent component + onSuccess(did, trimmedHandle); + } catch (error) { + console.error('Failed to resolve DID:', error); + setIsProcessing(false); + onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました'); + } + }; + + if (needsHandle) { + return ( +

+
+

Blueskyハンドルを入力してください

+

OAuth認証は成功しました。アカウントを完成させるためにハンドルを入力してください。

+

+ 入力中: {handle || '(未入力)'} | 文字数: {handle.length} +

+
+ { + console.log('Input changed:', e.target.value); + setHandle(e.target.value); + }} + placeholder="例: syui.ai または user.bsky.social" + autoFocus + style={{ + width: '100%', + padding: '10px', + marginTop: '20px', + marginBottom: '20px', + borderRadius: '8px', + border: '1px solid #ccc', + fontSize: '16px', + backgroundColor: '#1a1a1a', + color: 'white' + }} + /> + +
+
+
+ ); + } + + if (isProcessing) { + return ( +
+
+
+

認証処理中...

+

atproto認証を完了しています。しばらくお待ちください。

+
+
+ ); + } + + return null; +}; + +// CSS styles (inline for simplicity) +const styles = ` +.oauth-callback { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: linear-gradient(180deg, #0a0a0a 0%, #1a1a1a 100%); + color: white; +} + +.oauth-processing { + text-align: center; + padding: 40px; + background: rgba(255, 255, 255, 0.05); + border-radius: 16px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top: 3px solid #fff700; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.oauth-processing h2 { + margin-bottom: 10px; + font-size: 24px; +} + +.oauth-processing p { + opacity: 0.8; + margin: 0; +} +`; + +// Inject styles +const styleSheet = document.createElement('style'); +styleSheet.type = 'text/css'; +styleSheet.innerText = styles; +document.head.appendChild(styleSheet); \ No newline at end of file diff --git a/web/src/components/OAuthCallbackPage.tsx b/web/src/components/OAuthCallbackPage.tsx new file mode 100644 index 0000000..dc3d5e7 --- /dev/null +++ b/web/src/components/OAuthCallbackPage.tsx @@ -0,0 +1,42 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { OAuthCallback } from './OAuthCallback'; + +export const OAuthCallbackPage: React.FC = () => { + const navigate = useNavigate(); + + useEffect(() => { + console.log('=== OAUTH CALLBACK PAGE MOUNTED ==='); + console.log('Current URL:', window.location.href); + console.log('Search params:', window.location.search); + console.log('Pathname:', window.location.pathname); + }, []); + + const handleSuccess = (did: string, handle: string) => { + console.log('OAuth success, redirecting to home:', { did, handle }); + + // Add a small delay to ensure state is properly updated + setTimeout(() => { + navigate('/', { replace: true }); + }, 100); + }; + + const handleError = (error: string) => { + console.error('OAuth error, redirecting to home:', error); + + // Add a small delay before redirect + setTimeout(() => { + navigate('/', { replace: true }); + }, 2000); // Give user time to see error + }; + + return ( +
+

Processing OAuth callback...

+ +
+ ); +}; \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx index 0f6f19e..8a440ff 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,9 +1,21 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { BrowserRouter, Routes, Route } from 'react-router-dom' import App from './App' +import { OAuthCallbackPage } from './components/OAuthCallbackPage' +import { OAuthEndpointHandler } from './utils/oauth-endpoints' + +// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS +// DISABLED: This may interfere with BrowserOAuthClient +// OAuthEndpointHandler.init() ReactDOM.createRoot(document.getElementById('root')!).render( - + + + } /> + } /> + + , ) \ No newline at end of file diff --git a/web/src/services/api.ts b/web/src/services/api.ts index da733ab..778a25a 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1,18 +1,33 @@ import axios from 'axios'; import { CardDrawResult } from '../types/card'; -const API_BASE = '/api/v1'; +// ai.card 直接APIアクセス(メイン) +const API_HOST = import.meta.env.VITE_API_HOST || ''; +const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1'; -const api = axios.create({ +// ai.gpt MCP統合(オプション機能) +const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true' + ? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001') + : null; + +const cardApi_internal = axios.create({ baseURL: API_BASE, headers: { 'Content-Type': 'application/json', }, }); +const aiGptApi = AI_GPT_BASE ? axios.create({ + baseURL: AI_GPT_BASE, + headers: { + 'Content-Type': 'application/json', + }, +}) : null; + +// ai.cardの直接API(基本機能) export const cardApi = { drawCard: async (userDid: string, isPaid: boolean = false): Promise => { - const response = await api.post('/cards/draw', { + const response = await cardApi_internal.post('/cards/draw', { user_did: userDid, is_paid: isPaid, }); @@ -20,12 +35,73 @@ export const cardApi = { }, getUserCards: async (userDid: string) => { - const response = await api.get(`/cards/user/${userDid}`); + const response = await cardApi_internal.get(`/cards/user/${userDid}`); + return response.data; + }, + + getCardDetails: async (cardId: number) => { + const response = await cardApi_internal.get(`/cards/${cardId}`); return response.data; }, getUniqueCards: async () => { - const response = await api.get('/cards/unique'); + const response = await cardApi_internal.get('/cards/unique'); return response.data; }, + + getGachaStats: async () => { + const response = await cardApi_internal.get('/cards/stats'); + return response.data; + }, + + // システム状態確認 + getSystemStatus: async () => { + const response = await cardApi_internal.get('/health'); + return response.data; + }, +}; + +// ai.gpt統合API(オプション機能 - AI拡張) +export const aiCardApi = { + analyzeCollection: async (userDid: string) => { + if (!aiGptApi) { + throw new Error('AI機能が無効化されています'); + } + try { + const response = await aiGptApi.get('/card_analyze_collection', { + params: { did: userDid } + }); + return response.data.data; + } catch (error) { + console.warn('ai.gpt AI分析機能が利用できません:', error); + throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です'); + } + }, + + getEnhancedStats: async () => { + if (!aiGptApi) { + throw new Error('AI機能が無効化されています'); + } + try { + const response = await aiGptApi.get('/card_get_gacha_stats'); + return response.data.data; + } catch (error) { + console.warn('ai.gpt AI統計機能が利用できません:', error); + throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です'); + } + }, + + // AI機能が利用可能かチェック + isAIAvailable: async (): Promise => { + if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') { + return false; + } + + try { + await aiGptApi.get('/health'); + return true; + } catch (error) { + return false; + } + }, }; \ No newline at end of file diff --git a/web/src/services/atproto-oauth.ts b/web/src/services/atproto-oauth.ts new file mode 100644 index 0000000..df65f5a --- /dev/null +++ b/web/src/services/atproto-oauth.ts @@ -0,0 +1,616 @@ +import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; +import { Agent } from '@atproto/api'; + +interface AtprotoSession { + did: string; + handle: string; + accessJwt: string; + refreshJwt: string; + email?: string; + emailConfirmed?: boolean; +} + +class AtprotoOAuthService { + private oauthClient: BrowserOAuthClient | null = null; + private agent: Agent | null = null; + private initializePromise: Promise | null = null; + + constructor() { + // Don't initialize immediately, wait for first use + } + + private async initialize(): Promise { + // Prevent multiple initializations + if (this.initializePromise) { + return this.initializePromise; + } + + this.initializePromise = this._doInitialize(); + return this.initializePromise; + } + + private async _doInitialize(): Promise { + try { + console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ==='); + + // Generate client ID based on current origin + const clientId = this.getClientId(); + console.log('Client ID:', clientId); + + this.oauthClient = await BrowserOAuthClient.load({ + clientId: clientId, + handleResolver: 'https://bsky.social', + }); + + console.log('BrowserOAuthClient initialized successfully'); + + // Try to restore existing session + const result = await this.oauthClient.init(); + if (result?.session) { + console.log('Existing session restored:', { + did: result.session.did, + handle: result.session.handle || 'unknown', + hasAccessJwt: !!result.session.accessJwt, + hasRefreshJwt: !!result.session.refreshJwt + }); + + // Create Agent instance with proper configuration + console.log('Creating Agent with session:', result.session); + + // Delete the old agent initialization code - we'll create it properly below + + // Set the session after creating the agent + // The session object from BrowserOAuthClient appears to be a special object + console.log('Full session object:', result.session); + console.log('Session type:', typeof result.session); + console.log('Session constructor:', result.session?.constructor?.name); + + // Try to iterate over the session object + if (result.session) { + console.log('Session properties:'); + for (const key in result.session) { + console.log(` ${key}:`, result.session[key]); + } + + // Check if session has methods + const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session)); + console.log('Session methods:', methods); + } + + // BrowserOAuthClient might return a Session object that needs to be used with the agent + // Let's try to use the session object directly with the agent + if (result.session) { + // Process the session to extract DID and handle + const sessionData = await this.processSession(result.session); + console.log('Session processed during initialization:', sessionData); + } + + } else { + console.log('No existing session found'); + } + + } catch (error) { + console.error('Failed to initialize OAuth client:', error); + this.initializePromise = null; // Reset on error to allow retry + throw error; + } + } + + private async processSession(session: any): Promise<{ did: string; handle: string }> { + console.log('Processing session:', session); + + // Log full session structure + console.log('Session structure:'); + console.log('- sub:', session.sub); + console.log('- did:', session.did); + console.log('- handle:', session.handle); + console.log('- iss:', session.iss); + console.log('- aud:', session.aud); + + // Check if agent has properties we can access + if (session.agent) { + console.log('- agent:', session.agent); + console.log('- agent.did:', session.agent?.did); + console.log('- agent.handle:', session.agent?.handle); + } + + const did = session.sub || session.did; + let handle = session.handle || 'unknown'; + + // Create Agent directly with session (per official docs) + try { + this.agent = new Agent(session); + console.log('Agent created directly with session'); + + // Check if agent has session info after creation + console.log('Agent after creation:'); + console.log('- agent.did:', this.agent.did); + console.log('- agent.session:', this.agent.session); + if (this.agent.session) { + console.log('- agent.session.did:', this.agent.session.did); + console.log('- agent.session.handle:', this.agent.session.handle); + } + } catch (err) { + console.log('Failed to create Agent with session directly, trying dpopFetch method'); + // Fallback to dpopFetch method + this.agent = new Agent({ + service: session.server?.serviceEndpoint || 'https://bsky.social', + fetch: session.dpopFetch + }); + } + + // Store basic session info + (this as any)._sessionInfo = { did, handle }; + + // If handle is missing, try multiple methods to resolve it + if (!handle || handle === 'unknown') { + console.log('Handle not in session, attempting to resolve...'); + + // Method 1: Try using the agent to get profile + try { + await new Promise(resolve => setTimeout(resolve, 300)); + const profile = await this.agent.getProfile({ actor: did }); + if (profile.data.handle) { + handle = profile.data.handle; + (this as any)._sessionInfo.handle = handle; + console.log('Successfully resolved handle via getProfile:', handle); + return { did, handle }; + } + } catch (err) { + console.error('getProfile failed:', err); + } + + // Method 2: Try using describeRepo + try { + const repoDesc = await this.agent.com.atproto.repo.describeRepo({ + repo: did + }); + if (repoDesc.data.handle) { + handle = repoDesc.data.handle; + (this as any)._sessionInfo.handle = handle; + console.log('Got handle from describeRepo:', handle); + return { did, handle }; + } + } catch (err) { + console.error('describeRepo failed:', err); + } + + // Method 3: Hardcoded fallback for known DIDs + if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') { + handle = 'syui.ai'; + (this as any)._sessionInfo.handle = handle; + console.log('Using hardcoded handle for known DID'); + } + } + + return { did, handle }; + } + + private getClientId(): string { + const origin = window.location.origin; + + // For production (xxxcard.syui.ai), use the actual URL + if (origin.includes('xxxcard.syui.ai')) { + return `${origin}/client-metadata.json`; + } + + // For localhost development, use undefined for loopback client + // The BrowserOAuthClient will handle this automatically + if (origin.includes('localhost') || origin.includes('127.0.0.1')) { + console.log('Using loopback client for localhost development'); + return undefined as any; // Loopback client + } + + // Default: use origin-based client metadata + return `${origin}/client-metadata.json`; + } + + async initiateOAuthFlow(handle?: string): Promise { + try { + console.log('=== INITIATING OAUTH FLOW ==='); + + if (!this.oauthClient) { + console.log('OAuth client not initialized, initializing now...'); + await this.initialize(); + } + + if (!this.oauthClient) { + throw new Error('Failed to initialize OAuth client'); + } + + // If handle is not provided, prompt user + if (!handle) { + handle = prompt('Blueskyハンドルを入力してください (例: user.bsky.social):'); + if (!handle) { + throw new Error('Handle is required for authentication'); + } + } + + console.log('Starting OAuth flow for handle:', handle); + + // Start OAuth authorization flow + console.log('Calling oauthClient.authorize with handle:', handle); + + try { + const authUrl = await this.oauthClient.authorize(handle, { + scope: 'atproto transition:generic', + }); + + console.log('Authorization URL generated:', authUrl.toString()); + console.log('URL breakdown:', { + protocol: authUrl.protocol, + hostname: authUrl.hostname, + pathname: authUrl.pathname, + search: authUrl.search + }); + + // Store some debug info before redirect + sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({ + timestamp: new Date().toISOString(), + handle: handle, + authUrl: authUrl.toString(), + currentUrl: window.location.href + })); + + // Redirect to authorization server + console.log('About to redirect to:', authUrl.toString()); + window.location.href = authUrl.toString(); + } catch (authorizeError) { + console.error('oauthClient.authorize failed:', authorizeError); + console.error('Error details:', { + name: authorizeError.name, + message: authorizeError.message, + stack: authorizeError.stack + }); + throw authorizeError; + } + + } catch (error) { + console.error('Failed to initiate OAuth flow:', error); + throw new Error(`OAuth認証の開始に失敗しました: ${error}`); + } + } + + async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> { + try { + console.log('=== HANDLING OAUTH CALLBACK ==='); + console.log('Current URL:', window.location.href); + console.log('URL hash:', window.location.hash); + console.log('URL search:', window.location.search); + + // BrowserOAuthClient should automatically handle the callback + // We just need to initialize it and it will process the current URL + if (!this.oauthClient) { + console.log('OAuth client not initialized, initializing now...'); + await this.initialize(); + } + + if (!this.oauthClient) { + throw new Error('Failed to initialize OAuth client'); + } + + console.log('OAuth client ready, initializing to process callback...'); + + // Call init() again to process the callback URL + const result = await this.oauthClient.init(); + console.log('OAuth callback processing result:', result); + + if (result?.session) { + // Process the session + return this.processSession(result.session); + } + + // If no session yet, wait a bit and try again + console.log('No session found immediately, waiting...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Try to check session again + const sessionCheck = await this.checkSession(); + if (sessionCheck) { + console.log('Session found after delay:', sessionCheck); + return sessionCheck; + } + + console.warn('OAuth callback completed but no session was created'); + return null; + + } catch (error) { + console.error('OAuth callback handling failed:', error); + console.error('Error details:', { + name: error.name, + message: error.message, + stack: error.stack + }); + throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`); + } + } + + async checkSession(): Promise<{ did: string; handle: string } | null> { + try { + console.log('=== CHECK SESSION CALLED ==='); + + if (!this.oauthClient) { + console.log('No OAuth client, initializing...'); + await this.initialize(); + } + + if (!this.oauthClient) { + console.log('OAuth client initialization failed'); + return null; + } + + console.log('Running oauthClient.init() to check session...'); + const result = await this.oauthClient.init(); + console.log('oauthClient.init() result:', result); + + if (result?.session) { + // Use the common session processing method + return this.processSession(result.session); + } + + return null; + } catch (error) { + console.error('Session check failed:', error); + return null; + } + } + + getAgent(): Agent | null { + return this.agent; + } + + getSession(): AtprotoSession | null { + console.log('getSession called'); + console.log('Current state:', { + hasAgent: !!this.agent, + hasAgentSession: !!this.agent?.session, + hasOAuthClient: !!this.oauthClient, + hasSessionInfo: !!(this as any)._sessionInfo + }); + + // First check if we have an agent with session + if (this.agent?.session) { + const session = { + did: this.agent.session.did, + handle: this.agent.session.handle || 'unknown', + accessJwt: this.agent.session.accessJwt || '', + refreshJwt: this.agent.session.refreshJwt || '', + }; + console.log('Returning agent session:', session); + return session; + } + + // If no agent.session but we have stored session info, return that + if ((this as any)._sessionInfo) { + const session = { + did: (this as any)._sessionInfo.did, + handle: (this as any)._sessionInfo.handle, + accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch + refreshJwt: 'dpop-protected', + }; + console.log('Returning stored session info:', session); + return session; + } + + console.log('No session available'); + return null; + } + + isAuthenticated(): boolean { + return !!this.agent || !!(this as any)._sessionInfo; + } + + getUser(): { did: string; handle: string } | null { + const session = this.getSession(); + if (!session) return null; + + return { + did: session.did, + handle: session.handle + }; + } + + async logout(): Promise { + try { + console.log('=== LOGGING OUT ==='); + + // Clear Agent + this.agent = null; + console.log('Agent cleared'); + + // Clear BrowserOAuthClient session + if (this.oauthClient) { + console.log('Clearing OAuth client session...'); + try { + // BrowserOAuthClient may have a revoke or signOut method + if (typeof (this.oauthClient as any).signOut === 'function') { + await (this.oauthClient as any).signOut(); + console.log('OAuth client signed out'); + } else if (typeof (this.oauthClient as any).revoke === 'function') { + await (this.oauthClient as any).revoke(); + console.log('OAuth client revoked'); + } else { + console.log('No explicit signOut method found on OAuth client'); + } + } catch (oauthError) { + console.error('OAuth client logout error:', oauthError); + } + + // Reset the OAuth client to force re-initialization + this.oauthClient = null; + this.initializePromise = null; + } + + // Clear any stored session data + localStorage.removeItem('atproto_session'); + sessionStorage.clear(); + + // Clear all localStorage items that might be related to OAuth + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => { + console.log('Removing localStorage key:', key); + localStorage.removeItem(key); + }); + + console.log('=== LOGOUT COMPLETED ==='); + + // Force page reload to ensure clean state + setTimeout(() => { + window.location.reload(); + }, 100); + + } catch (error) { + console.error('Logout failed:', error); + } + } + + // カードデータをatproto collectionに保存 + async saveCardToBox(userCards: any[]): Promise { + // Ensure we have a valid session + const sessionInfo = await this.checkSession(); + if (!sessionInfo) { + throw new Error('認証が必要です。ログインしてください。'); + } + + const did = sessionInfo.did; + + try { + console.log('Saving cards to atproto collection...'); + console.log('Using DID:', did); + + // Ensure we have a fresh agent + if (!this.agent) { + throw new Error('Agentが初期化されていません。'); + } + + const collection = 'ai.card.box'; + const rkey = 'self'; + const createdAt = new Date().toISOString(); + + // カードボックスのレコード + const record = { + $type: 'ai.card.box', + cards: userCards.map(card => ({ + id: card.id, + cp: card.cp, + status: card.status, + skill: card.skill, + owner_did: card.owner_did, + obtained_at: card.obtained_at, + is_unique: card.is_unique, + unique_id: card.unique_id, + img: `https://git.syui.ai/ai/ai/raw/branch/main/img/item/card/${card.id}.webp` + })), + total_cards: userCards.length, + updated_at: createdAt, + createdAt: createdAt + }; + + console.log('PutRecord request:', { + repo: did, + collection: collection, + rkey: rkey, + record: record + }); + + + // Use Agent's com.atproto.repo.putRecord method + const response = await this.agent.com.atproto.repo.putRecord({ + repo: did, + collection: collection, + rkey: rkey, + record: record + }); + + console.log('カードデータをai.card.boxに保存しました:', response); + } catch (error) { + console.error('カードボックス保存エラー:', error); + throw error; + } + } + + // ai.card.boxからカード一覧を取得 + async getCardsFromBox(): Promise { + // Ensure we have a valid session + const sessionInfo = await this.checkSession(); + if (!sessionInfo) { + throw new Error('認証が必要です。ログインしてください。'); + } + + const did = sessionInfo.did; + + try { + console.log('Fetching cards from atproto collection...'); + console.log('Using DID:', did); + + // Ensure we have a fresh agent + if (!this.agent) { + throw new Error('Agentが初期化されていません。'); + } + + const response = await this.agent.com.atproto.repo.getRecord({ + repo: did, + collection: 'ai.card.box', + rkey: 'self' + }); + + console.log('Cards from box response:', response); + + // Convert to expected format + const result = { + records: [{ + uri: `at://${did}/ai.card.box/self`, + cid: response.data.cid, + value: response.data.value + }] + }; + + return result; + } catch (error) { + console.error('カードボックス取得エラー:', error); + + // If record doesn't exist, return empty + if (error.toString().includes('RecordNotFound')) { + return { records: [] }; + } + + throw error; + } + } + + // 手動でトークンを設定(開発・デバッグ用) + setManualTokens(accessJwt: string, refreshJwt: string): void { + console.warn('Manual token setting is not supported with official BrowserOAuthClient'); + console.warn('Please use the proper OAuth flow instead'); + + // For backward compatibility, store in localStorage + const session: AtprotoSession = { + did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', + handle: 'syui.ai', + accessJwt: accessJwt, + refreshJwt: refreshJwt + }; + + localStorage.setItem('atproto_session', JSON.stringify(session)); + console.log('Manual tokens stored in localStorage for backward compatibility'); + } + + // 後方互換性のための従来関数 + saveSessionToStorage(session: AtprotoSession): void { + console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient'); + localStorage.setItem('atproto_session', JSON.stringify(session)); + } + + async backupUserCards(userCards: any[]): Promise { + return this.saveCardToBox(userCards); + } +} + +export const atprotoOAuthService = new AtprotoOAuthService(); +export type { AtprotoSession }; \ No newline at end of file diff --git a/web/src/styles/CardBox.css b/web/src/styles/CardBox.css new file mode 100644 index 0000000..a50bc57 --- /dev/null +++ b/web/src/styles/CardBox.css @@ -0,0 +1,179 @@ +.card-box-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.card-box-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #e9ecef; +} + +.card-box-header h3 { + color: #495057; + margin: 0; + font-size: 24px; +} + +.box-actions { + display: flex; + gap: 10px; +} + +.uri-display { + background: #e3f2fd; + border: 1px solid #bbdefb; + border-radius: 8px; + padding: 12px; + margin-bottom: 20px; +} + +.uri-display p { + margin: 0; + color: #1565c0; + font-size: 14px; +} + +.uri-display code { + background: #ffffff; + border: 1px solid #90caf9; + border-radius: 4px; + padding: 4px 8px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + color: #0d47a1; + word-break: break-all; +} + +.json-button, +.refresh-button, +.retry-button { + padding: 8px 16px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; +} + +.json-button { + background: linear-gradient(135deg, #6f42c1 0%, #8b5fc3 100%); + color: white; +} + +.json-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4); +} + +.refresh-button { + background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%); + color: white; +} + +.refresh-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4); +} + +.retry-button { + background: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%); + color: white; +} + +.retry-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(253, 126, 20, 0.4); +} + +.json-display { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; +} + +.json-display h4 { + color: #495057; + margin-top: 0; + margin-bottom: 15px; +} + +.json-content { + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 4px; + padding: 15px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + color: #495057; + max-height: 400px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; +} + +.box-stats { + background: rgba(102, 126, 234, 0.1); + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; +} + +.box-stats p { + margin: 0; + color: #495057; + font-size: 14px; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.box-card-item { + text-align: center; +} + +.card-info { + margin-top: 8px; + color: #6c757d; + font-size: 12px; +} + +.empty-box { + text-align: center; + padding: 40px 20px; + color: #6c757d; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #dee2e6; +} + +.empty-box p { + margin: 8px 0; +} + +.loading, +.error { + text-align: center; + padding: 40px 20px; + color: #6c757d; + font-size: 16px; +} + +.error { + color: #dc3545; + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 8px; +} \ No newline at end of file diff --git a/web/src/styles/CollectionAnalysis.css b/web/src/styles/CollectionAnalysis.css new file mode 100644 index 0000000..7ff0679 --- /dev/null +++ b/web/src/styles/CollectionAnalysis.css @@ -0,0 +1,172 @@ +.collection-analysis { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 16px; + padding: 24px; + margin: 20px 0; + color: white; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.collection-analysis h3 { + margin: 0 0 20px 0; + font-size: 1.5rem; + font-weight: 600; + text-align: center; +} + +.analysis-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + border-radius: 12px; + padding: 16px; + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.stat-value { + font-size: 2rem; + font-weight: bold; + margin-bottom: 4px; +} + +.stat-label { + font-size: 0.9rem; + opacity: 0.8; +} + +.rarity-distribution { + margin-bottom: 24px; +} + +.rarity-distribution h4 { + margin: 0 0 16px 0; + font-size: 1.2rem; + font-weight: 500; +} + +.rarity-bars { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rarity-bar { + display: flex; + align-items: center; + gap: 12px; +} + +.rarity-name { + min-width: 80px; + font-weight: 500; + text-transform: capitalize; +} + +.bar-container { + flex: 1; + height: 20px; + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; + overflow: hidden; +} + +.bar { + height: 100%; + border-radius: 10px; + transition: width 0.3s ease; +} + +.bar-common { background: linear-gradient(90deg, #4CAF50, #45a049); } +.bar-rare { background: linear-gradient(90deg, #2196F3, #1976D2); } +.bar-epic { background: linear-gradient(90deg, #9C27B0, #7B1FA2); } +.bar-legendary { background: linear-gradient(90deg, #FF9800, #F57C00); } +.bar-mythic { background: linear-gradient(90deg, #F44336, #D32F2F); } + +.rarity-count { + min-width: 40px; + text-align: right; + font-weight: 500; +} + +.recommendations { + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 16px; + margin-bottom: 20px; +} + +.recommendations h4 { + margin: 0 0 12px 0; + font-size: 1.1rem; +} + +.recommendations ul { + margin: 0; + padding-left: 20px; +} + +.recommendations li { + margin-bottom: 8px; + line-height: 1.4; +} + +.refresh-analysis, +.analyze-button, +.retry-button { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + border-radius: 8px; + padding: 12px 24px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: block; + margin: 0 auto; +} + +.refresh-analysis:hover, +.analyze-button:hover, +.retry-button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +.analysis-loading, +.analysis-error, +.analysis-empty { + text-align: center; + padding: 40px 20px; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top: 3px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.analysis-error p { + color: #ffcdd2; + margin-bottom: 16px; +} + +.analysis-empty p { + opacity: 0.8; + margin-bottom: 16px; +} \ No newline at end of file diff --git a/web/src/styles/GachaAnimation.css b/web/src/styles/GachaAnimation.css index 23febb6..b068e65 100644 --- a/web/src/styles/GachaAnimation.css +++ b/web/src/styles/GachaAnimation.css @@ -9,6 +9,60 @@ justify-content: center; background: rgba(0, 0, 0, 0.9); z-index: 1000; + cursor: pointer; +} + +.card-final { + position: relative; + text-align: center; +} + +.card-actions { + position: absolute; + bottom: -80px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.save-button { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 10px 20px; + border-radius: 25px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); +} + +.save-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); +} + +.save-button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.click-hint { + color: white; + font-size: 12px; + background: rgba(0, 0, 0, 0.7); + padding: 6px 12px; + border-radius: 15px; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } } .gacha-opening { diff --git a/web/src/styles/GachaStats.css b/web/src/styles/GachaStats.css new file mode 100644 index 0000000..4ae5aba --- /dev/null +++ b/web/src/styles/GachaStats.css @@ -0,0 +1,219 @@ +.gacha-stats { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 16px; + padding: 24px; + margin: 20px 0; + color: white; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); +} + +.gacha-stats h3 { + margin: 0 0 20px 0; + font-size: 1.5rem; + font-weight: 600; + text-align: center; +} + +.stats-overview { + margin-bottom: 24px; + text-align: center; +} + +.overview-card { + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + border-radius: 12px; + padding: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); + display: inline-block; + min-width: 200px; +} + +.overview-value { + font-size: 2.5rem; + font-weight: bold; + margin-bottom: 8px; +} + +.overview-label { + font-size: 1rem; + opacity: 0.9; +} + +.rarity-stats { + margin-bottom: 24px; +} + +.rarity-stats h4 { + margin: 0 0 16px 0; + font-size: 1.2rem; + font-weight: 500; +} + +.rarity-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +} + +.rarity-stat { + background: rgba(255, 255, 255, 0.15); + backdrop-filter: blur(10px); + border-radius: 12px; + padding: 16px; + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.2); + position: relative; + overflow: hidden; +} + +.rarity-stat::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--rarity-color); +} + +.rarity-stat.rarity-common { --rarity-color: #4CAF50; } +.rarity-stat.rarity-rare { --rarity-color: #2196F3; } +.rarity-stat.rarity-epic { --rarity-color: #9C27B0; } +.rarity-stat.rarity-legendary { --rarity-color: #FF9800; } +.rarity-stat.rarity-mythic { --rarity-color: #F44336; } + +.rarity-count { + font-size: 1.8rem; + font-weight: bold; + margin-bottom: 4px; +} + +.rarity-name { + font-size: 0.9rem; + opacity: 0.9; + text-transform: capitalize; + margin-bottom: 4px; +} + +.success-rate { + font-size: 0.8rem; + opacity: 0.7; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 2px 6px; + display: inline-block; +} + +.recent-activity { + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 16px; + margin-bottom: 20px; +} + +.recent-activity h4 { + margin: 0 0 12px 0; + font-size: 1.1rem; +} + +.activity-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.activity-item { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.activity-time { + font-size: 0.8rem; + opacity: 0.7; + min-width: 120px; +} + +.activity-details { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + justify-content: flex-end; +} + +.card-rarity { + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; +} + +.card-rarity.rarity-common { background: #4CAF50; } +.card-rarity.rarity-rare { background: #2196F3; } +.card-rarity.rarity-epic { background: #9C27B0; } +.card-rarity.rarity-legendary { background: #FF9800; } +.card-rarity.rarity-mythic { background: #F44336; } + +.card-name { + font-weight: 500; +} + +.refresh-stats, +.load-stats-button, +.retry-button { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + border-radius: 8px; + padding: 12px 24px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: block; + margin: 0 auto; +} + +.refresh-stats:hover, +.load-stats-button:hover, +.retry-button:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); +} + +.stats-loading, +.stats-error, +.stats-empty { + text-align: center; + padding: 40px 20px; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top: 3px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.stats-error p { + color: #ffcdd2; + margin-bottom: 16px; +} + +.stats-empty p { + opacity: 0.8; + margin-bottom: 16px; +} \ No newline at end of file diff --git a/web/src/styles/Login.css b/web/src/styles/Login.css index 4dc82e6..f4b6747 100644 --- a/web/src/styles/Login.css +++ b/web/src/styles/Login.css @@ -17,11 +17,91 @@ border: 1px solid #444; border-radius: 16px; padding: 40px; - max-width: 400px; + max-width: 450px; width: 90%; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } +.login-mode-selector { + display: flex; + margin-bottom: 24px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 4px; +} + +.mode-button { + flex: 1; + padding: 12px 16px; + border: none; + background: transparent; + color: #ccc; + border-radius: 6px; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 500; +} + +.mode-button.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); +} + +.mode-button:hover:not(.active) { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.oauth-login { + text-align: center; +} + +.oauth-info { + margin-bottom: 24px; + padding: 20px; + background: rgba(102, 126, 234, 0.1); + border-radius: 12px; + border: 1px solid rgba(102, 126, 234, 0.3); +} + +.oauth-info h3 { + margin: 0 0 12px 0; + font-size: 18px; + color: #667eea; +} + +.oauth-info p { + margin: 0; + font-size: 14px; + line-height: 1.5; + opacity: 0.9; +} + +.oauth-login-button { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: white; + padding: 16px 32px; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3); +} + +.oauth-login-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); +} + +.oauth-login-button:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + .login-modal h2 { margin: 0 0 30px 0; font-size: 28px; @@ -149,4 +229,15 @@ font-size: 14px; line-height: 1.6; margin: 0; +} + +.dev-notice { + background: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.3); + border-radius: 6px; + padding: 8px 12px; + margin: 10px 0; + color: #ffc107; + font-size: 12px; + text-align: center; } \ No newline at end of file diff --git a/web/src/utils/oauth-endpoints.ts b/web/src/utils/oauth-endpoints.ts new file mode 100644 index 0000000..9e7ab1a --- /dev/null +++ b/web/src/utils/oauth-endpoints.ts @@ -0,0 +1,141 @@ +/** + * OAuth dynamic endpoint handlers + */ +import { OAuthKeyManager, generateClientMetadata } from './oauth-keys'; + +export class OAuthEndpointHandler { + /** + * Initialize OAuth endpoint handlers + */ + static init() { + // Intercept requests to client-metadata.json + this.setupClientMetadataHandler(); + + // Intercept requests to .well-known/jwks.json + this.setupJWKSHandler(); + } + + private static setupClientMetadataHandler() { + // Override fetch for client-metadata.json requests + const originalFetch = window.fetch; + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + + // Only intercept local OAuth endpoints + try { + const urlObj = new URL(url, window.location.origin); + + // Only intercept requests to the same origin + if (urlObj.origin !== window.location.origin) { + // Pass through external API calls unchanged + return originalFetch(input, init); + } + + // Handle local OAuth endpoints + if (urlObj.pathname.endsWith('/client-metadata.json')) { + const metadata = generateClientMetadata(); + return new Response(JSON.stringify(metadata, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } + + if (urlObj.pathname.endsWith('/.well-known/jwks.json')) { + try { + const jwks = await OAuthKeyManager.getJWKS(); + return new Response(JSON.stringify(jwks, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*' + } + }); + } catch (error) { + console.error('Failed to generate JWKS:', error); + return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + } catch (e) { + // If URL parsing fails, pass through to original fetch + console.debug('URL parsing failed, passing through:', e); + } + + // Pass through all other requests + return originalFetch(input, init); + }; + } + + private static setupJWKSHandler() { + // This is handled in the fetch override above + } + + /** + * Generate a proper client assertion JWT for token requests + */ + static async generateClientAssertion(tokenEndpoint: string): Promise { + const now = Math.floor(Date.now() / 1000); + const clientId = generateClientMetadata().client_id; + + const header = { + alg: 'ES256', + typ: 'JWT', + kid: 'ai-card-oauth-key-1' + }; + + const payload = { + iss: clientId, + sub: clientId, + aud: tokenEndpoint, + iat: now, + exp: now + 300, // 5 minutes + jti: crypto.randomUUID() + }; + + return await OAuthKeyManager.signJWT(header, payload); + } +} + +/** + * Service Worker alternative for intercepting requests + * (This is a more robust solution for production) + */ +export function registerOAuthServiceWorker() { + if ('serviceWorker' in navigator) { + const swCode = ` + self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + if (url.pathname.endsWith('/client-metadata.json')) { + event.respondWith( + new Response(JSON.stringify({ + client_id: url.origin + '/client-metadata.json', + client_name: 'ai.card', + client_uri: url.origin, + redirect_uris: [url.origin + '/oauth/callback'], + response_types: ['code'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'private_key_jwt', + scope: 'atproto transition:generic', + subject_type: 'public', + application_type: 'web', + dpop_bound_access_tokens: true, + jwks_uri: url.origin + '/.well-known/jwks.json' + }, null, 2), { + headers: { 'Content-Type': 'application/json' } + }) + ); + } + }); + `; + + const blob = new Blob([swCode], { type: 'application/javascript' }); + const swUrl = URL.createObjectURL(blob); + + navigator.serviceWorker.register(swUrl).catch(console.error); + } +} \ No newline at end of file diff --git a/web/src/utils/oauth-keys.ts b/web/src/utils/oauth-keys.ts new file mode 100644 index 0000000..85282d2 --- /dev/null +++ b/web/src/utils/oauth-keys.ts @@ -0,0 +1,204 @@ +/** + * OAuth JWKS key generation and management + */ + +export interface JWK { + kty: string; + crv: string; + x: string; + y: string; + d?: string; + use: string; + kid: string; + alg: string; +} + +export interface JWKS { + keys: JWK[]; +} + +export class OAuthKeyManager { + private static keyPair: CryptoKeyPair | null = null; + private static jwks: JWKS | null = null; + + /** + * Generate or retrieve existing ECDSA key pair for OAuth + */ + static async getKeyPair(): Promise { + if (this.keyPair) { + return this.keyPair; + } + + // Try to load from localStorage first + const storedKey = localStorage.getItem('oauth_private_key'); + if (storedKey) { + try { + const keyData = JSON.parse(storedKey); + this.keyPair = await this.importKeyPair(keyData); + return this.keyPair; + } catch (error) { + console.warn('Failed to load stored key, generating new one:', error); + localStorage.removeItem('oauth_private_key'); + } + } + + // Generate new key pair + this.keyPair = await window.crypto.subtle.generateKey( + { + name: 'ECDSA', + namedCurve: 'P-256', + }, + true, // extractable + ['sign', 'verify'] + ); + + // Store private key for persistence + await this.storeKeyPair(this.keyPair); + + return this.keyPair; + } + + /** + * Get JWKS (JSON Web Key Set) for public key distribution + */ + static async getJWKS(): Promise { + if (this.jwks) { + return this.jwks; + } + + const keyPair = await this.getKeyPair(); + const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey); + + this.jwks = { + keys: [ + { + kty: publicKey.kty!, + crv: publicKey.crv!, + x: publicKey.x!, + y: publicKey.y!, + use: 'sig', + kid: 'ai-card-oauth-key-1', + alg: 'ES256' + } + ] + }; + + return this.jwks; + } + + /** + * Sign a JWT with the private key + */ + static async signJWT(header: any, payload: any): Promise { + const keyPair = await this.getKeyPair(); + + const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, ''); + const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, ''); + const message = `${headerB64}.${payloadB64}`; + + const signature = await window.crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + keyPair.privateKey, + new TextEncoder().encode(message) + ); + + const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + + return `${message}.${signatureB64}`; + } + + private static async storeKeyPair(keyPair: CryptoKeyPair): Promise { + try { + const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey); + localStorage.setItem('oauth_private_key', JSON.stringify(privateKey)); + } catch (error) { + console.error('Failed to store private key:', error); + } + } + + private static async importKeyPair(keyData: any): Promise { + const privateKey = await window.crypto.subtle.importKey( + 'jwk', + keyData, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign'] + ); + + // Derive public key from private key + const publicKeyData = { ...keyData }; + delete publicKeyData.d; // Remove private component + + const publicKey = await window.crypto.subtle.importKey( + 'jwk', + publicKeyData, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['verify'] + ); + + return { privateKey, publicKey }; + } + + /** + * Clear stored keys (for testing/reset) + */ + static clearKeys(): void { + localStorage.removeItem('oauth_private_key'); + this.keyPair = null; + this.jwks = null; + } +} + +/** + * Generate dynamic client metadata based on current URL + */ +export function generateClientMetadata(): any { + const origin = window.location.origin; + const clientId = `${origin}/client-metadata.json`; + + // Use static production metadata for xxxcard.syui.ai + if (origin === 'https://xxxcard.syui.ai') { + return { + client_id: 'https://xxxcard.syui.ai/client-metadata.json', + client_name: 'ai.card', + client_uri: 'https://xxxcard.syui.ai', + logo_uri: 'https://xxxcard.syui.ai/favicon.ico', + tos_uri: 'https://xxxcard.syui.ai/terms', + policy_uri: 'https://xxxcard.syui.ai/privacy', + redirect_uris: ['https://xxxcard.syui.ai/oauth/callback'], + response_types: ['code'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'private_key_jwt', + token_endpoint_auth_signing_alg: 'ES256', + scope: 'atproto transition:generic', + subject_type: 'public', + application_type: 'web', + dpop_bound_access_tokens: true, + jwks_uri: 'https://xxxcard.syui.ai/.well-known/jwks.json' + }; + } + + // Dynamic metadata for development + return { + client_id: clientId, + client_name: 'ai.card', + client_uri: origin, + logo_uri: `${origin}/favicon.ico`, + tos_uri: `${origin}/terms`, + policy_uri: `${origin}/privacy`, + redirect_uris: [`${origin}/oauth/callback`], + response_types: ['code'], + grant_types: ['authorization_code', 'refresh_token'], + token_endpoint_auth_method: 'private_key_jwt', + token_endpoint_auth_signing_alg: 'ES256', + scope: 'atproto transition:generic', + subject_type: 'public', + application_type: 'web', + dpop_bound_access_tokens: true, + jwks_uri: `${origin}/.well-known/jwks.json` + }; +} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts index 6dc68ec..0fb8cad 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,13 +3,29 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + build: { + // Keep console.log in production for debugging + minify: 'esbuild', + }, + esbuild: { + drop: [], // Don't drop console.log + }, server: { - port: 3000, + port: 5173, + host: '127.0.0.1', + allowedHosts: ['localhost', '127.0.0.1', 'xxxcard.syui.ai'], proxy: { '/api': { - target: 'http://localhost:8000', + target: 'http://127.0.0.1:8000', changeOrigin: true, + secure: false, } + }, + // Handle OAuth callback routing + historyApiFallback: { + rewrites: [ + { from: /^\/oauth\/callback/, to: '/index.html' } + ] } } }) \ No newline at end of file