fix oauth package name
This commit is contained in:
		
							
								
								
									
										206
									
								
								oauth/src/utils/avatar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								oauth/src/utils/avatar.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,206 @@ | ||||
| import React from 'react' | ||||
| import { atproto } from '../api/atproto.js' | ||||
| import { getPdsFromHandle, getApiConfig } from './pds.js' | ||||
| import { dataCache } from './cache.js' | ||||
| import { logError } from './errorHandler.js' | ||||
|  | ||||
| // Cache duration for avatar URLs (30 minutes) | ||||
| const AVATAR_CACHE_DURATION = 30 * 60 * 1000 | ||||
|  | ||||
| /** | ||||
|  * Avatar fetching utility with fallback mechanism | ||||
|  *  | ||||
|  * Strategy: | ||||
|  * 1. First check if avatar exists in the record | ||||
|  * 2. If avatar is missing/broken, fetch fresh data from ATProto | ||||
|  * 3. Cache results to avoid excessive API calls | ||||
|  */ | ||||
|  | ||||
| /** | ||||
|  * Extract avatar URL from record if available | ||||
|  * @param {Object} record - The record object | ||||
|  * @returns {string|null} Avatar URL or null | ||||
|  */ | ||||
| function getAvatarFromRecord(record) { | ||||
|   const avatar = record?.value?.author?.avatar | ||||
|   if (avatar && typeof avatar === 'string' && avatar.startsWith('http')) { | ||||
|     return avatar | ||||
|   } | ||||
|   return null | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fetch fresh avatar data from ATProto | ||||
|  * @param {string} handle - User handle | ||||
|  * @param {string} did - User DID (optional, will fetch if not provided) | ||||
|  * @returns {Promise<string|null>} Avatar URL or null | ||||
|  */ | ||||
| async function fetchFreshAvatar(handle, did = null) { | ||||
|   try { | ||||
|     // Step 1: Get PDS from handle | ||||
|     const pds = await getPdsFromHandle(handle) | ||||
|     const apiConfig = getApiConfig(pds) | ||||
|      | ||||
|     // Step 2: Get DID if not provided | ||||
|     if (!did) { | ||||
|       const pdsHost = pds.replace(/^https?:\/\//, '') | ||||
|       const repoData = await atproto.getDid(pdsHost, handle) | ||||
|       did = repoData | ||||
|     } | ||||
|      | ||||
|     // Step 3: Get profile from bsky API | ||||
|     const profile = await atproto.getProfile(apiConfig.bsky, did) | ||||
|      | ||||
|     // Return avatar URL | ||||
|     return profile?.avatar || null | ||||
|   } catch (error) { | ||||
|     logError(error, 'Avatar Fetch') | ||||
|     return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get avatar with intelligent fallback | ||||
|  * @param {Object} options - Options object | ||||
|  * @param {Object} options.record - Record object (optional) | ||||
|  * @param {string} options.handle - User handle (required if no record) | ||||
|  * @param {string} options.did - User DID (optional) | ||||
|  * @param {boolean} options.forceFresh - Force fresh fetch even if cached | ||||
|  * @returns {Promise<string|null>} Avatar URL or null | ||||
|  */ | ||||
| export async function getAvatar({ record, handle, did, forceFresh = false }) { | ||||
|   // Extract handle and DID from record if available | ||||
|   if (record && !handle) { | ||||
|     handle = record.value?.author?.handle | ||||
|     did = record.value?.author?.did | ||||
|   } | ||||
|    | ||||
|   if (!handle) { | ||||
|     throw new Error('Handle is required to fetch avatar') | ||||
|   } | ||||
|    | ||||
|   // Generate cache key | ||||
|   const cacheKey = `avatar:${handle}` | ||||
|    | ||||
|   // Check cache first (unless forceFresh) | ||||
|   if (!forceFresh) { | ||||
|     const cached = dataCache.get(cacheKey) | ||||
|     if (cached) { | ||||
|       return cached | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // Try to get avatar from record first | ||||
|   if (record) { | ||||
|     const recordAvatar = getAvatarFromRecord(record) | ||||
|     if (recordAvatar) { | ||||
|       // Validate that the avatar URL is still accessible | ||||
|       try { | ||||
|         const response = await fetch(recordAvatar, { method: 'HEAD' }) | ||||
|         if (response.ok) { | ||||
|           dataCache.set(cacheKey, recordAvatar, AVATAR_CACHE_DURATION) | ||||
|           return recordAvatar | ||||
|         } | ||||
|       } catch { | ||||
|         // Avatar URL is broken, proceed to fetch fresh | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // Fetch fresh avatar data | ||||
|   const freshAvatar = await fetchFreshAvatar(handle, did) | ||||
|    | ||||
|   if (freshAvatar) { | ||||
|     dataCache.set(cacheKey, freshAvatar, AVATAR_CACHE_DURATION) | ||||
|   } | ||||
|    | ||||
|   return freshAvatar | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Batch fetch avatars for multiple users | ||||
|  * @param {Array<Object>} users - Array of user objects with handle/did | ||||
|  * @returns {Promise<Map>} Map of handle -> avatar URL | ||||
|  */ | ||||
| export async function batchFetchAvatars(users) { | ||||
|   const avatarMap = new Map() | ||||
|    | ||||
|   // Process in parallel with concurrency limit | ||||
|   const BATCH_SIZE = 5 | ||||
|   for (let i = 0; i < users.length; i += BATCH_SIZE) { | ||||
|     const batch = users.slice(i, i + BATCH_SIZE) | ||||
|     const promises = batch.map(async (user) => { | ||||
|       const avatar = await getAvatar({ | ||||
|         handle: user.handle, | ||||
|         did: user.did | ||||
|       }) | ||||
|       return { handle: user.handle, avatar } | ||||
|     }) | ||||
|      | ||||
|     const results = await Promise.all(promises) | ||||
|     results.forEach(({ handle, avatar }) => { | ||||
|       avatarMap.set(handle, avatar) | ||||
|     }) | ||||
|   } | ||||
|    | ||||
|   return avatarMap | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Prefetch and cache avatar for a handle | ||||
|  * @param {string} handle - User handle | ||||
|  * @returns {Promise<void>} | ||||
|  */ | ||||
| export async function prefetchAvatar(handle) { | ||||
|   await getAvatar({ handle }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Clear avatar cache for a specific handle | ||||
|  * @param {string} handle - User handle | ||||
|  */ | ||||
| export function clearAvatarCache(handle) { | ||||
|   if (handle) { | ||||
|     dataCache.delete(`avatar:${handle}`) | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Clear all avatar caches | ||||
|  */ | ||||
| export function clearAllAvatarCaches() { | ||||
|   dataCache.invalidatePattern('avatar:') | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * React hook for avatar management | ||||
|  * @param {Object} options - Options for avatar fetching | ||||
|  * @returns {Object} { avatar, loading, error, refetch } | ||||
|  */ | ||||
| export function useAvatar({ record, handle, did }) { | ||||
|   const [state, setState] = React.useState({ | ||||
|     avatar: null, | ||||
|     loading: true, | ||||
|     error: null | ||||
|   }) | ||||
|    | ||||
|   const fetchAvatar = React.useCallback(async (forceFresh = false) => { | ||||
|     setState(prev => ({ ...prev, loading: true, error: null })) | ||||
|      | ||||
|     try { | ||||
|       const avatarUrl = await getAvatar({ record, handle, did, forceFresh }) | ||||
|       setState({ avatar: avatarUrl, loading: false, error: null }) | ||||
|     } catch (error) { | ||||
|       setState({ avatar: null, loading: false, error: error.message }) | ||||
|     } | ||||
|   }, [record, handle, did]) | ||||
|    | ||||
|   React.useEffect(() => { | ||||
|     fetchAvatar() | ||||
|   }, [fetchAvatar]) | ||||
|    | ||||
|   return { | ||||
|     ...state, | ||||
|     refetch: () => fetchAvatar(true) | ||||
|   } | ||||
| } | ||||
							
								
								
									
										262
									
								
								oauth/src/utils/avatarCache.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										262
									
								
								oauth/src/utils/avatarCache.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,262 @@ | ||||
| import { dataCache } from './cache.js' | ||||
|  | ||||
| /** | ||||
|  * Avatar-specific cache utilities | ||||
|  * Extends the base cache system with avatar-specific functionality | ||||
|  */ | ||||
|  | ||||
| // Cache keys | ||||
| const CACHE_PREFIX = 'avatar:' | ||||
| const METADATA_KEY = 'avatar:metadata' | ||||
|  | ||||
| /** | ||||
|  * Get cache metadata for avatars | ||||
|  * @returns {Object} Metadata about avatar cache | ||||
|  */ | ||||
| export function getAvatarCacheMetadata() { | ||||
|   return dataCache.get(METADATA_KEY) || { | ||||
|     totalCount: 0, | ||||
|     lastCleanup: Date.now(), | ||||
|     cacheHits: 0, | ||||
|     cacheMisses: 0 | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Update cache metadata | ||||
|  * @param {Object} updates - Updates to apply to metadata | ||||
|  */ | ||||
| function updateMetadata(updates) { | ||||
|   const current = getAvatarCacheMetadata() | ||||
|   const updated = { ...current, ...updates } | ||||
|   dataCache.set(METADATA_KEY, updated) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Track cache hit | ||||
|  */ | ||||
| export function trackCacheHit() { | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   updateMetadata({ cacheHits: metadata.cacheHits + 1 }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Track cache miss | ||||
|  */ | ||||
| export function trackCacheMiss() { | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   updateMetadata({ cacheMisses: metadata.cacheMisses + 1 }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get all cached avatar handles | ||||
|  * @returns {Array<string>} List of cached handles | ||||
|  */ | ||||
| export function getCachedAvatarHandles() { | ||||
|   // This would require enumerating cache keys | ||||
|   // For now, we'll track this in metadata | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   return metadata.handles || [] | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Add handle to cached list | ||||
|  * @param {string} handle - Handle to add | ||||
|  */ | ||||
| export function addCachedHandle(handle) { | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   const handles = metadata.handles || [] | ||||
|   if (!handles.includes(handle)) { | ||||
|     handles.push(handle) | ||||
|     updateMetadata({  | ||||
|       handles, | ||||
|       totalCount: handles.length | ||||
|     }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Remove handle from cached list | ||||
|  * @param {string} handle - Handle to remove | ||||
|  */ | ||||
| export function removeCachedHandle(handle) { | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   const handles = (metadata.handles || []).filter(h => h !== handle) | ||||
|   updateMetadata({  | ||||
|     handles, | ||||
|     totalCount: handles.length | ||||
|   }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Clean up expired avatar cache entries | ||||
|  * @param {number} maxAge - Maximum age in milliseconds (default: 30 minutes) | ||||
|  * @returns {number} Number of entries cleaned | ||||
|  */ | ||||
| export function cleanupExpiredAvatars(maxAge = 30 * 60 * 1000) { | ||||
|   const now = Date.now() | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   const handles = metadata.handles || [] | ||||
|   let cleanedCount = 0 | ||||
|  | ||||
|   handles.forEach(handle => { | ||||
|     const cacheKey = `${CACHE_PREFIX}${handle}` | ||||
|     const entry = dataCache.get(cacheKey, true) // Get with metadata | ||||
|      | ||||
|     if (entry && entry.timestamp && (now - entry.timestamp) > maxAge) { | ||||
|       dataCache.delete(cacheKey) | ||||
|       cleanedCount++ | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   // Update metadata | ||||
|   if (cleanedCount > 0) { | ||||
|     const remainingHandles = handles.filter(handle => { | ||||
|       const cacheKey = `${CACHE_PREFIX}${handle}` | ||||
|       return dataCache.get(cacheKey) !== null | ||||
|     }) | ||||
|      | ||||
|     updateMetadata({ | ||||
|       handles: remainingHandles, | ||||
|       totalCount: remainingHandles.length, | ||||
|       lastCleanup: now | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   return cleanedCount | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get cache statistics | ||||
|  * @returns {Object} Cache statistics | ||||
|  */ | ||||
| export function getAvatarCacheStats() { | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   const totalRequests = metadata.cacheHits + metadata.cacheMisses | ||||
|   const hitRate = totalRequests > 0 ? (metadata.cacheHits / totalRequests * 100) : 0 | ||||
|  | ||||
|   return { | ||||
|     totalCached: metadata.totalCount || 0, | ||||
|     cacheHits: metadata.cacheHits || 0, | ||||
|     cacheMisses: metadata.cacheMisses || 0, | ||||
|     hitRate: Math.round(hitRate * 100) / 100, | ||||
|     lastCleanup: metadata.lastCleanup ? new Date(metadata.lastCleanup) : null | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Clear all avatar cache data | ||||
|  * @returns {number} Number of entries cleared | ||||
|  */ | ||||
| export function clearAllAvatarCache() { | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   const handles = metadata.handles || [] | ||||
|    | ||||
|   handles.forEach(handle => { | ||||
|     const cacheKey = `${CACHE_PREFIX}${handle}` | ||||
|     dataCache.delete(cacheKey) | ||||
|   }) | ||||
|  | ||||
|   // Clear metadata | ||||
|   dataCache.delete(METADATA_KEY) | ||||
|    | ||||
|   return handles.length | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Preload avatars for a list of handles | ||||
|  * @param {Array<string>} handles - Handles to preload | ||||
|  * @param {Function} getAvatar - Avatar fetching function | ||||
|  * @returns {Promise<Map>} Map of handle -> avatar URL results | ||||
|  */ | ||||
| export async function preloadAvatars(handles, getAvatar) { | ||||
|   const results = new Map() | ||||
|   const BATCH_SIZE = 3 // Smaller batch for preloading | ||||
|  | ||||
|   for (let i = 0; i < handles.length; i += BATCH_SIZE) { | ||||
|     const batch = handles.slice(i, i + BATCH_SIZE) | ||||
|     const promises = batch.map(async (handle) => { | ||||
|       try { | ||||
|         const avatar = await getAvatar({ handle }) | ||||
|         return { handle, avatar, success: true } | ||||
|       } catch (error) { | ||||
|         return { handle, avatar: null, success: false, error: error.message } | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     const batchResults = await Promise.all(promises) | ||||
|     batchResults.forEach(({ handle, avatar, success }) => { | ||||
|       results.set(handle, { avatar, success }) | ||||
|       if (success) { | ||||
|         addCachedHandle(handle) | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     // Small delay between batches to avoid overwhelming the API | ||||
|     if (i + BATCH_SIZE < handles.length) { | ||||
|       await new Promise(resolve => setTimeout(resolve, 100)) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return results | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Validate cached avatar URLs | ||||
|  * Check if cached avatar URLs are still valid | ||||
|  * @param {number} sampleSize - Number of cached avatars to validate (default: 5) | ||||
|  * @returns {Promise<Object>} Validation results | ||||
|  */ | ||||
| export async function validateCachedAvatars(sampleSize = 5) { | ||||
|   const metadata = getAvatarCacheMetadata() | ||||
|   const handles = metadata.handles || [] | ||||
|    | ||||
|   if (handles.length === 0) { | ||||
|     return { validCount: 0, invalidCount: 0, totalChecked: 0 } | ||||
|   } | ||||
|  | ||||
|   // Sample random handles to check | ||||
|   const samplesToCheck = handles | ||||
|     .sort(() => Math.random() - 0.5) | ||||
|     .slice(0, sampleSize) | ||||
|  | ||||
|   let validCount = 0 | ||||
|   let invalidCount = 0 | ||||
|  | ||||
|   for (const handle of samplesToCheck) { | ||||
|     const cacheKey = `${CACHE_PREFIX}${handle}` | ||||
|     const avatarUrl = dataCache.get(cacheKey) | ||||
|      | ||||
|     if (avatarUrl && typeof avatarUrl === 'string' && avatarUrl.startsWith('http')) { | ||||
|       try { | ||||
|         const response = await fetch(avatarUrl, { method: 'HEAD' }) | ||||
|         if (response.ok) { | ||||
|           validCount++ | ||||
|         } else { | ||||
|           invalidCount++ | ||||
|           // Remove invalid cached avatar | ||||
|           dataCache.delete(cacheKey) | ||||
|           removeCachedHandle(handle) | ||||
|         } | ||||
|       } catch { | ||||
|         invalidCount++ | ||||
|         // Remove invalid cached avatar | ||||
|         dataCache.delete(cacheKey) | ||||
|         removeCachedHandle(handle) | ||||
|       } | ||||
|     } else { | ||||
|       invalidCount++ | ||||
|       // Remove invalid cache entry | ||||
|       dataCache.delete(cacheKey) | ||||
|       removeCachedHandle(handle) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     validCount, | ||||
|     invalidCount, | ||||
|     totalChecked: samplesToCheck.length, | ||||
|     validationRate: samplesToCheck.length > 0 ?  | ||||
|       Math.round((validCount / samplesToCheck.length) * 100) : 0 | ||||
|   } | ||||
| } | ||||
							
								
								
									
										147
									
								
								oauth/src/utils/avatarFetcher.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								oauth/src/utils/avatarFetcher.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | ||||
| import { getPdsFromHandle, getApiConfig } from './pds.js' | ||||
| import { logger } from './logger.js' | ||||
|  | ||||
| // Avatar取得の状態管理 | ||||
| const avatarCache = new Map() | ||||
| const CACHE_DURATION = 30 * 60 * 1000 // 30分 | ||||
|  | ||||
| // Avatar URLが有効かチェック | ||||
| async function isAvatarValid(avatarUrl) { | ||||
|   if (!avatarUrl) return false | ||||
|    | ||||
|   try { | ||||
|     const response = await fetch(avatarUrl, { method: 'HEAD' }) | ||||
|     return response.ok | ||||
|   } catch (error) { | ||||
|     logger.warn('Avatar URL check failed:', error) | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| // handleからDIDを取得 | ||||
| async function getDid(handle) { | ||||
|   try { | ||||
|     const pds = await getPdsFromHandle(handle) | ||||
|     const response = await fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`) | ||||
|     const data = await response.json() | ||||
|     return data.did | ||||
|   } catch (error) { | ||||
|     logger.error('Failed to get DID for handle:', handle, error) | ||||
|     throw error | ||||
|   } | ||||
| } | ||||
|  | ||||
| // DIDからプロフィール情報を取得 | ||||
| async function getProfile(did, handle) { | ||||
|   try { | ||||
|     // Determine which public API to use based on handle | ||||
|     const pds = await getPdsFromHandle(handle) | ||||
|     const apiConfig = getApiConfig(pds) | ||||
|      | ||||
|     // Use the appropriate public API endpoint | ||||
|     const publicApiUrl = apiConfig.bsky | ||||
|      | ||||
|     logger.log('Getting profile for DID:', did, 'using public API:', publicApiUrl) | ||||
|     const response = await fetch(`${publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`) | ||||
|      | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`Profile API error: ${response.status} ${response.statusText}`) | ||||
|     } | ||||
|      | ||||
|     const data = await response.json() | ||||
|     logger.log('Profile data received:', data) | ||||
|     return data | ||||
|   } catch (error) { | ||||
|     logger.error('Failed to get profile for DID:', did, error) | ||||
|     throw error | ||||
|   } | ||||
| } | ||||
|  | ||||
| // 新しいavatar URLを取得 | ||||
| async function fetchFreshAvatar(handle, did) { | ||||
|   const cacheKey = `${handle}:${did || 'no-did'}` | ||||
|   const cached = avatarCache.get(cacheKey) | ||||
|    | ||||
|   // キャッシュチェック | ||||
|   if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { | ||||
|     logger.log('Using cached avatar for:', handle) | ||||
|     return cached.avatar | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     logger.log('Fetching fresh avatar for handle:', handle, 'with DID:', did) | ||||
|      | ||||
|     // DIDが不明な場合は取得 | ||||
|     let actualDid = did | ||||
|     if (!actualDid) { | ||||
|       logger.log('No DID provided, fetching from handle:', handle) | ||||
|       actualDid = await getDid(handle) | ||||
|       logger.log('Got DID from handle:', actualDid) | ||||
|     } | ||||
|      | ||||
|     // プロフィール取得 | ||||
|     const profile = await getProfile(actualDid, handle) | ||||
|     const avatarUrl = profile.avatar || null | ||||
|      | ||||
|     // キャッシュに保存 | ||||
|     avatarCache.set(cacheKey, { | ||||
|       avatar: avatarUrl, | ||||
|       timestamp: Date.now(), | ||||
|       profile: { | ||||
|         displayName: profile.displayName, | ||||
|         handle: profile.handle | ||||
|       } | ||||
|     }) | ||||
|      | ||||
|     logger.log('Fresh avatar fetched for:', handle, 'Avatar URL:', avatarUrl) | ||||
|     return avatarUrl | ||||
|      | ||||
|   } catch (error) { | ||||
|     logger.error('Failed to fetch fresh avatar for:', handle, 'Error:', error) | ||||
|     return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| // メイン関数: avatarを取得(recordから → 新規取得) | ||||
| export async function getValidAvatar(record) { | ||||
|   const author = record?.value?.author | ||||
|   if (!author?.handle) { | ||||
|     logger.warn('No handle found in record author') | ||||
|     return null | ||||
|   } | ||||
|  | ||||
|   const { handle, did, avatar: recordAvatar } = author | ||||
|  | ||||
|   // 1. record内のavatarをチェック | ||||
|   if (recordAvatar) { | ||||
|     const isValid = await isAvatarValid(recordAvatar) | ||||
|     if (isValid) { | ||||
|       logger.log('Using avatar from record:', recordAvatar) | ||||
|       return recordAvatar | ||||
|     } else { | ||||
|       logger.log('Record avatar is broken, fetching fresh:', recordAvatar) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // 2. 新しいavatarを取得 | ||||
|   return await fetchFreshAvatar(handle, did) | ||||
| } | ||||
|  | ||||
| // キャッシュクリア | ||||
| export function clearAvatarCache() { | ||||
|   avatarCache.clear() | ||||
|   logger.log('Avatar cache cleared') | ||||
| } | ||||
|  | ||||
| // キャッシュ統計 | ||||
| export function getAvatarCacheStats() { | ||||
|   return { | ||||
|     size: avatarCache.size, | ||||
|     entries: Array.from(avatarCache.entries()).map(([key, value]) => ({ | ||||
|       key, | ||||
|       avatar: value.avatar, | ||||
|       age: Date.now() - value.timestamp, | ||||
|       profile: value.profile | ||||
|     })) | ||||
|   } | ||||
| } | ||||
							
								
								
									
										63
									
								
								oauth/src/utils/cache.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								oauth/src/utils/cache.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| import { logger } from './logger.js' | ||||
|  | ||||
| class SimpleCache { | ||||
|   constructor(ttl = 30000) { // 30秒TTL | ||||
|     this.cache = new Map() | ||||
|     this.ttl = ttl | ||||
|   } | ||||
|  | ||||
|   generateKey(...parts) { | ||||
|     return parts.filter(Boolean).join(':') | ||||
|   } | ||||
|  | ||||
|   get(key) { | ||||
|     const item = this.cache.get(key) | ||||
|     if (!item) return null | ||||
|      | ||||
|     if (Date.now() - item.timestamp > this.ttl) { | ||||
|       this.cache.delete(key) | ||||
|       return null | ||||
|     } | ||||
|      | ||||
|     logger.log(`Cache hit: ${key}`) | ||||
|     return item.data | ||||
|   } | ||||
|  | ||||
|   set(key, data) { | ||||
|     this.cache.set(key, { | ||||
|       data, | ||||
|       timestamp: Date.now() | ||||
|     }) | ||||
|     logger.log(`Cache set: ${key}`) | ||||
|   } | ||||
|  | ||||
|   clear() { | ||||
|     this.cache.clear() | ||||
|     logger.log('Cache cleared') | ||||
|   } | ||||
|  | ||||
|   invalidatePattern(pattern) { | ||||
|     let deletedCount = 0 | ||||
|     for (const key of this.cache.keys()) { | ||||
|       if (key.includes(pattern)) { | ||||
|         this.cache.delete(key) | ||||
|         deletedCount++ | ||||
|       } | ||||
|     } | ||||
|     logger.log(`Cache invalidated: ${pattern} (${deletedCount} items)`) | ||||
|   } | ||||
|  | ||||
|   getStats() { | ||||
|     return { | ||||
|       size: this.cache.size, | ||||
|       keys: Array.from(this.cache.keys()) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const dataCache = new SimpleCache() | ||||
|  | ||||
| // デバッグ用:開発環境でのみグローバルからアクセス可能にする | ||||
| if (import.meta.env.DEV) { | ||||
|   window.dataCache = dataCache | ||||
| } | ||||
							
								
								
									
										49
									
								
								oauth/src/utils/errorHandler.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								oauth/src/utils/errorHandler.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import { logger } from './logger.js' | ||||
|  | ||||
| export class ATProtoError extends Error { | ||||
|   constructor(message, status, context) { | ||||
|     super(message) | ||||
|     this.status = status | ||||
|     this.context = context | ||||
|     this.timestamp = new Date().toISOString() | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function getErrorMessage(error) { | ||||
|   if (!error) return '不明なエラー' | ||||
|    | ||||
|   if (error.status === 400) { | ||||
|     return 'アカウントまたはレコードが見つかりません' | ||||
|   } else if (error.status === 401) { | ||||
|     return '認証が必要です。ログインしてください' | ||||
|   } else if (error.status === 403) { | ||||
|     return 'アクセス権限がありません' | ||||
|   } else if (error.status === 429) { | ||||
|     return 'アクセスが集中しています。しばらく待ってから再試行してください' | ||||
|   } else if (error.status === 500) { | ||||
|     return 'サーバーでエラーが発生しました' | ||||
|   } else if (error.message?.includes('fetch')) { | ||||
|     return 'ネットワーク接続を確認してください' | ||||
|   } else if (error.message?.includes('timeout')) { | ||||
|     return 'タイムアウトしました。再試行してください' | ||||
|   } | ||||
|    | ||||
|   return `エラーが発生しました: ${error.message || '不明'}` | ||||
| } | ||||
|  | ||||
| export function logError(error, context = 'Unknown') { | ||||
|   const errorInfo = { | ||||
|     context, | ||||
|     message: error.message, | ||||
|     status: error.status, | ||||
|     timestamp: new Date().toISOString(), | ||||
|     url: window.location.href | ||||
|   } | ||||
|    | ||||
|   logger.error(`[ATProto Error] ${context}:`, errorInfo) | ||||
|    | ||||
|   // 本番環境では外部ログサービスに送信することも可能 | ||||
|   // if (import.meta.env.PROD) { | ||||
|   //   sendToLogService(errorInfo) | ||||
|   // } | ||||
| } | ||||
							
								
								
									
										82
									
								
								oauth/src/utils/logger.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								oauth/src/utils/logger.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| // Logger utility with environment-based control | ||||
| class Logger { | ||||
|   constructor() { | ||||
|     this.isDev = import.meta.env.DEV || false | ||||
|     this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true' | ||||
|     this.isEnabled = this.isDev && this.debugEnabled // Enable only in dev AND when debug flag is true | ||||
|   } | ||||
|  | ||||
|   log(...args) { | ||||
|     if (this.isEnabled) { | ||||
|       console.log(...args) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   error(...args) { | ||||
|     if (this.isEnabled) { | ||||
|       console.error(...args) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   warn(...args) { | ||||
|     if (this.isEnabled) { | ||||
|       console.warn(...args) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   info(...args) { | ||||
|     if (this.isEnabled) { | ||||
|       console.info(...args) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // グループログ | ||||
|   group(label) { | ||||
|     if (this.isEnabled) { | ||||
|       console.group(label) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   groupEnd() { | ||||
|     if (this.isEnabled) { | ||||
|       console.groupEnd() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // テーブル表示 | ||||
|   table(data) { | ||||
|     if (this.isEnabled) { | ||||
|       console.table(data) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // 時間計測 | ||||
|   time(label) { | ||||
|     if (this.isEnabled) { | ||||
|       console.time(label) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   timeEnd(label) { | ||||
|     if (this.isEnabled) { | ||||
|       console.timeEnd(label) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // ログを有効/無効にする | ||||
|   enable() { | ||||
|     this.isEnabled = true | ||||
|   } | ||||
|  | ||||
|   disable() { | ||||
|     this.isEnabled = false | ||||
|   } | ||||
| } | ||||
|  | ||||
| // シングルトンインスタンス | ||||
| export const logger = new Logger() | ||||
|  | ||||
| // 開発環境でのみグローバルアクセス可能にする | ||||
| if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_DEBUG === 'true') { | ||||
|   window._logger = logger | ||||
| } | ||||
| @@ -1,138 +0,0 @@ | ||||
| /** | ||||
|  * 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) { | ||||
|             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 | ||||
|       } | ||||
|        | ||||
|       // 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<string> { | ||||
|     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); | ||||
|      | ||||
|   } | ||||
| } | ||||
| @@ -1,181 +0,0 @@ | ||||
| /** | ||||
|  * 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<CryptoKeyPair> { | ||||
|     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) { | ||||
|         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<JWKS> { | ||||
|     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<string> { | ||||
|     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<void> { | ||||
|     try { | ||||
|       const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey); | ||||
|       localStorage.setItem('oauth_private_key', JSON.stringify(privateKey)); | ||||
|     } catch (error) { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private static async importKeyPair(keyData: any): Promise<CryptoKeyPair> { | ||||
|     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 { | ||||
|   // Use environment variables if available, fallback to current origin | ||||
|   const host = import.meta.env.VITE_APP_HOST || window.location.origin; | ||||
|   const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID || `${host}/client-metadata.json`; | ||||
|   const redirectUri = import.meta.env.VITE_OAUTH_REDIRECT_URI || `${host}/oauth/callback`; | ||||
|  | ||||
|   return { | ||||
|     client_id: clientId, | ||||
|     client_name: 'ai.card', | ||||
|     client_uri: host, | ||||
|     logo_uri: `${host}/favicon.ico`, | ||||
|     tos_uri: `${host}/terms`, | ||||
|     policy_uri: `${host}/privacy`, | ||||
|     redirect_uris: [redirectUri, host], | ||||
|     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: `${host}/.well-known/jwks.json` | ||||
|   }; | ||||
| } | ||||
| @@ -1,348 +0,0 @@ | ||||
| // PDS Detection and API URL mapping utilities | ||||
|  | ||||
| import { isValidDid, isValidHandle } from './validation'; | ||||
|  | ||||
| export interface NetworkConfig { | ||||
|   pdsApi: string; | ||||
|   plcApi: string; | ||||
|   bskyApi: string; | ||||
|   webUrl: string; | ||||
| } | ||||
|  | ||||
| // Detect PDS from handle | ||||
| export function detectPdsFromHandle(handle: string): string { | ||||
|   // Get allowed handles from environment | ||||
|   const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]'; | ||||
|   let allowedHandles: string[] = []; | ||||
|   try { | ||||
|     allowedHandles = JSON.parse(allowedHandlesStr); | ||||
|   } catch { | ||||
|     allowedHandles = []; | ||||
|   } | ||||
|    | ||||
|   // Get configured PDS from environment | ||||
|   const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is'; | ||||
|    | ||||
|   // Check if handle is in allowed list | ||||
|   if (allowedHandles.includes(handle)) { | ||||
|     return configuredPds; | ||||
|   } | ||||
|    | ||||
|   // Check if handle ends with .syu.is or .syui.ai | ||||
|   if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) { | ||||
|     return 'syu.is'; | ||||
|   } | ||||
|    | ||||
|   // Check if handle ends with .bsky.social or .bsky.app | ||||
|   if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) { | ||||
|     return 'bsky.social'; | ||||
|   } | ||||
|    | ||||
|   // Default to Bluesky for unknown domains | ||||
|   return 'bsky.social'; | ||||
| } | ||||
|  | ||||
| // Map PDS endpoint to network configuration | ||||
| export function getNetworkConfigFromPdsEndpoint(pdsEndpoint: string): NetworkConfig { | ||||
|   try { | ||||
|     const url = new URL(pdsEndpoint); | ||||
|     const hostname = url.hostname; | ||||
|      | ||||
|     // Map based on actual PDS endpoint | ||||
|     if (hostname === 'syu.is') { | ||||
|       return { | ||||
|         pdsApi: 'https://syu.is',           // PDS API (repo operations) | ||||
|         plcApi: 'https://plc.syu.is',       // PLC directory | ||||
|         bskyApi: 'https://bsky.syu.is',     // Bluesky API (getProfile, etc.) | ||||
|         webUrl: 'https://web.syu.is'        // Web interface | ||||
|       }; | ||||
|     } else if (hostname.includes('bsky.network') || hostname === 'bsky.social' || hostname.includes('host.bsky.network')) { | ||||
|       // All Bluesky infrastructure (including *.host.bsky.network) | ||||
|       return { | ||||
|         pdsApi: pdsEndpoint,                      // Use actual PDS endpoint (e.g., shiitake.us-east.host.bsky.network) | ||||
|         plcApi: 'https://plc.directory',          // Standard PLC directory | ||||
|         bskyApi: 'https://public.api.bsky.app',   // Bluesky public API (NOT PDS) | ||||
|         webUrl: 'https://bsky.app'                // Bluesky web interface | ||||
|       }; | ||||
|     } else { | ||||
|       // Unknown PDS, assume Bluesky-compatible but use PDS for repo operations | ||||
|       return { | ||||
|         pdsApi: pdsEndpoint,                      // Use actual PDS for repo ops | ||||
|         plcApi: 'https://plc.directory',          // Default PLC | ||||
|         bskyApi: 'https://public.api.bsky.app',   // Default to Bluesky API | ||||
|         webUrl: 'https://bsky.app'                // Default web interface | ||||
|       }; | ||||
|     } | ||||
|   } catch (error) { | ||||
|     // Fallback for invalid URLs | ||||
|     return { | ||||
|       pdsApi: 'https://bsky.social', | ||||
|       plcApi: 'https://plc.directory', | ||||
|       bskyApi: 'https://public.api.bsky.app', | ||||
|       webUrl: 'https://bsky.app' | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Legacy function for backwards compatibility | ||||
| export function getNetworkConfig(pds: string): NetworkConfig { | ||||
|   // This now assumes pds is a hostname | ||||
|   return getNetworkConfigFromPdsEndpoint(`https://${pds}`); | ||||
| } | ||||
|  | ||||
| // Get appropriate API URL for a user based on their handle | ||||
| export function getApiUrlForUser(handle: string): string { | ||||
|   const pds = detectPdsFromHandle(handle); | ||||
|   const config = getNetworkConfig(pds); | ||||
|   return config.bskyApi; | ||||
| } | ||||
|  | ||||
| // Resolve handle/DID to actual PDS endpoint using PLC API first | ||||
| export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> { | ||||
|   // Validate input | ||||
|   if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) { | ||||
|     throw new Error(`Invalid identifier: ${handleOrDid}`); | ||||
|   } | ||||
|    | ||||
|   let targetDid = handleOrDid; | ||||
|   let targetHandle = handleOrDid; | ||||
|    | ||||
|   // If handle provided, resolve to DID first using identity.resolveHandle | ||||
|   if (!handleOrDid.startsWith('did:')) { | ||||
|     try { | ||||
|       // Try multiple endpoints for handle resolution | ||||
|       const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is']; | ||||
|       let resolved = false; | ||||
|        | ||||
|       for (const endpoint of resolveEndpoints) { | ||||
|         try { | ||||
|           const resolveResponse = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`); | ||||
|           if (resolveResponse.ok) { | ||||
|             const resolveData = await resolveResponse.json(); | ||||
|             targetDid = resolveData.did; | ||||
|             resolved = true; | ||||
|             break; | ||||
|           } | ||||
|         } catch (error) { | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       if (!resolved) { | ||||
|         throw new Error('Handle resolution failed from all endpoints'); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       throw new Error(`Failed to resolve handle ${handleOrDid} to DID: ${error}`); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // First, try PLC API to get the authoritative DID document | ||||
|   const plcApis = ['https://plc.directory', 'https://plc.syu.is']; | ||||
|    | ||||
|   for (const plcApi of plcApis) { | ||||
|     try { | ||||
|       const plcResponse = await fetch(`${plcApi}/${targetDid}`); | ||||
|       if (plcResponse.ok) { | ||||
|         const didDocument = await plcResponse.json(); | ||||
|          | ||||
|         // Find PDS service in DID document | ||||
|         const pdsService = didDocument.service?.find((s: any) =>  | ||||
|           s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' | ||||
|         ); | ||||
|          | ||||
|         if (pdsService && pdsService.serviceEndpoint) { | ||||
|           return { | ||||
|             pds: pdsService.serviceEndpoint, | ||||
|             did: targetDid, | ||||
|             handle: targetHandle | ||||
|           }; | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       continue; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   // Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints | ||||
|   const pdsEndpoints = ['https://bsky.social', 'https://syu.is']; | ||||
|    | ||||
|   for (const pdsEndpoint of pdsEndpoints) { | ||||
|     try { | ||||
|       const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(targetDid)}`); | ||||
|        | ||||
|       if (response.ok) { | ||||
|         const data = await response.json(); | ||||
|          | ||||
|         // Extract PDS from didDoc.service | ||||
|         const services = data.didDoc?.service || []; | ||||
|         const pdsService = services.find((s: any) =>  | ||||
|           s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' | ||||
|         ); | ||||
|          | ||||
|         if (pdsService) { | ||||
|           return { | ||||
|             pds: pdsService.serviceEndpoint, | ||||
|             did: data.did || targetDid, | ||||
|             handle: data.handle || targetHandle | ||||
|           }; | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       continue; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   throw new Error(`Failed to resolve PDS for ${handleOrDid} from any endpoint`); | ||||
| } | ||||
|  | ||||
| // Resolve DID to actual PDS endpoint using com.atproto.repo.describeRepo | ||||
| export async function resolvePdsFromDid(did: string): Promise<string> { | ||||
|   const resolved = await resolvePdsFromRepo(did); | ||||
|   return resolved.pds; | ||||
| } | ||||
|  | ||||
| // Enhanced resolve handle to DID with proper PDS detection | ||||
| export async function resolveHandleToDid(handle: string): Promise<{ did: string; pds: string }> { | ||||
|   try { | ||||
|     // First, try to resolve the handle to DID using multiple methods | ||||
|     const apiUrl = getApiUrlForUser(handle); | ||||
|     const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); | ||||
|      | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`Failed to resolve handle: ${response.status}`); | ||||
|     } | ||||
|      | ||||
|     const data = await response.json(); | ||||
|     const did = data.did; | ||||
|      | ||||
|     // Now resolve the actual PDS from the DID | ||||
|     const actualPds = await resolvePdsFromDid(did); | ||||
|      | ||||
|     return { | ||||
|       did: did, | ||||
|       pds: actualPds | ||||
|     }; | ||||
|   } catch (error) { | ||||
|     // Failed to resolve handle | ||||
|      | ||||
|     // Fallback to handle-based detection | ||||
|     const fallbackPds = detectPdsFromHandle(handle); | ||||
|     throw error; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Get profile using appropriate API for the user with accurate PDS resolution | ||||
| export async function getProfileForUser(handleOrDid: string, knownPdsEndpoint?: string): Promise<any> { | ||||
|   try { | ||||
|     let apiUrl: string; | ||||
|      | ||||
|     if (knownPdsEndpoint) { | ||||
|       // If we already know the user's PDS endpoint, use it directly | ||||
|       const config = getNetworkConfigFromPdsEndpoint(knownPdsEndpoint); | ||||
|       apiUrl = config.bskyApi; | ||||
|     } else { | ||||
|       // Resolve the user's actual PDS using describeRepo | ||||
|       try { | ||||
|         const resolved = await resolvePdsFromRepo(handleOrDid); | ||||
|         const config = getNetworkConfigFromPdsEndpoint(resolved.pds); | ||||
|         apiUrl = config.bskyApi; | ||||
|       } catch { | ||||
|         // Fallback to handle-based detection | ||||
|         apiUrl = getApiUrlForUser(handleOrDid); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`); | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`Failed to get profile: ${response.status}`); | ||||
|     } | ||||
|      | ||||
|     return await response.json(); | ||||
|   } catch (error) { | ||||
|     // Failed to get profile | ||||
|      | ||||
|     // Final fallback: try with default Bluesky API | ||||
|     try { | ||||
|       const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`); | ||||
|       if (response.ok) { | ||||
|         return await response.json(); | ||||
|       } | ||||
|     } catch { | ||||
|       // Ignore fallback errors | ||||
|     } | ||||
|      | ||||
|     throw error; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Test and verify PDS detection methods | ||||
| export async function verifyPdsDetection(handleOrDid: string): Promise<void> { | ||||
|   try { | ||||
|     // Method 1: com.atproto.repo.describeRepo (PRIMARY METHOD) | ||||
|     try { | ||||
|       const resolved = await resolvePdsFromRepo(handleOrDid); | ||||
|       const config = getNetworkConfigFromPdsEndpoint(resolved.pds); | ||||
|     } catch (error) { | ||||
|       // describeRepo failed | ||||
|     } | ||||
|      | ||||
|     // Method 2: com.atproto.identity.resolveHandle (for comparison) | ||||
|     if (!handleOrDid.startsWith('did:')) { | ||||
|       try { | ||||
|         const resolveResponse = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`); | ||||
|         if (resolveResponse.ok) { | ||||
|           const resolveData = await resolveResponse.json(); | ||||
|         } | ||||
|       } catch (error) { | ||||
|         // Error resolving handle | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // Method 3: PLC Directory lookup (if we have a DID) | ||||
|     let targetDid = handleOrDid; | ||||
|     if (!handleOrDid.startsWith('did:')) { | ||||
|       try { | ||||
|         const profile = await getProfileForUser(handleOrDid); | ||||
|         targetDid = profile.did; | ||||
|       } catch { | ||||
|         return; | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     try { | ||||
|       const plcResponse = await fetch(`https://plc.directory/${targetDid}`); | ||||
|       if (plcResponse.ok) { | ||||
|         const didDocument = await plcResponse.json(); | ||||
|          | ||||
|         // Find PDS service | ||||
|         const pdsService = didDocument.service?.find((s: any) =>  | ||||
|           s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' | ||||
|         ); | ||||
|          | ||||
|         if (pdsService) { | ||||
|           // Try to detect if this is a known network | ||||
|           const pdsUrl = pdsService.serviceEndpoint; | ||||
|           const hostname = new URL(pdsUrl).hostname; | ||||
|           const detectedNetwork = detectPdsFromHandle(`user.${hostname}`); | ||||
|           const networkConfig = getNetworkConfig(hostname); | ||||
|         } | ||||
|       } | ||||
|     } catch (error) { | ||||
|       // Error fetching from PLC directory | ||||
|     } | ||||
|      | ||||
|     // Method 4: Our enhanced resolution | ||||
|     try { | ||||
|       if (handleOrDid.startsWith('did:')) { | ||||
|         const pdsEndpoint = await resolvePdsFromDid(handleOrDid); | ||||
|       } else { | ||||
|         const resolved = await resolveHandleToDid(handleOrDid); | ||||
|       } | ||||
|     } catch (error) { | ||||
|       // Enhanced resolution failed | ||||
|     } | ||||
|      | ||||
|   } catch (error) { | ||||
|     // Overall verification failed | ||||
|   } | ||||
| } | ||||
							
								
								
									
										36
									
								
								oauth/src/utils/pds.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								oauth/src/utils/pds.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| import { env } from '../config/env.js' | ||||
|  | ||||
| // PDS判定からAPI設定を取得 | ||||
| export function getApiConfig(pds) { | ||||
|   if (pds.includes(env.pds)) { | ||||
|     return { | ||||
|       pds: `https://${env.pds}`, | ||||
|       bsky: `https://bsky.${env.pds}`, | ||||
|       plc: `https://plc.${env.pds}`, | ||||
|       web: `https://web.${env.pds}` | ||||
|     } | ||||
|   } | ||||
|   return { | ||||
|     pds: pds.startsWith('http') ? pds : `https://${pds}`, | ||||
|     bsky: 'https://public.api.bsky.app', | ||||
|     plc: 'https://plc.directory', | ||||
|     web: 'https://bsky.app' | ||||
|   } | ||||
| } | ||||
|  | ||||
| // handleがsyu.is系かどうか判定 | ||||
| export function isSyuIsHandle(handle) { | ||||
|   return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`) | ||||
| } | ||||
|  | ||||
| // handleからPDS取得 | ||||
| export async function getPdsFromHandle(handle) { | ||||
|   const initialPds = isSyuIsHandle(handle)  | ||||
|     ? `https://${env.pds}`  | ||||
|     : 'https://bsky.social' | ||||
|    | ||||
|   const data = await fetch(`${initialPds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`) | ||||
|     .then(res => res.json()) | ||||
|    | ||||
|   return data.didDoc?.service?.[0]?.serviceEndpoint || initialPds | ||||
| } | ||||
| @@ -1,21 +0,0 @@ | ||||
| // Validation utilities for atproto identifiers | ||||
|  | ||||
| export function isValidDid(did: string): boolean { | ||||
|   if (!did || typeof did !== 'string') return false; | ||||
|    | ||||
|   // Basic DID format: did:method:identifier | ||||
|   const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/; | ||||
|   return didRegex.test(did); | ||||
| } | ||||
|  | ||||
| export function isValidHandle(handle: string): boolean { | ||||
|   if (!handle || typeof handle !== 'string') return false; | ||||
|    | ||||
|   // Basic handle format: subdomain.domain.tld | ||||
|   const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/; | ||||
|   return handleRegex.test(handle); | ||||
| } | ||||
|  | ||||
| export function isValidAtprotoIdentifier(identifier: string): boolean { | ||||
|   return isValidDid(identifier) || isValidHandle(identifier); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user