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