diff --git a/Cargo.toml b/Cargo.toml index b69545e..dba2945 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,10 @@ license = "MIT" name = "ailog" path = "src/main.rs" +[lib] +name = "ailog" +path = "src/lib.rs" + [dependencies] clap = { version = "4.5", features = ["derive"] } pulldown-cmark = "0.11" diff --git a/my-blog/config.toml b/my-blog/config.toml index 626b3a6..5ffdffe 100644 --- a/my-blog/config.toml +++ b/my-blog/config.toml @@ -28,4 +28,4 @@ redirect = "oauth/callback" admin = "ai.syui.ai" collection = "ai.syui.log" pds = "syu.is" -handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"] +handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"] diff --git a/my-blog/oauth/.env.production b/my-blog/oauth/.env.production index b02737b..5ab0fb0 100644 --- a/my-blog/oauth/.env.production +++ b/my-blog/oauth/.env.production @@ -9,7 +9,7 @@ VITE_ADMIN_HANDLE=ai.syui.ai VITE_AI_HANDLE=ai.syui.ai VITE_OAUTH_COLLECTION=ai.syui.log VITE_ATPROTO_WEB_URL=https://bsky.app -VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"] +VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"] # AI Configuration VITE_AI_ENABLED=true diff --git a/my-blog/static/client-metadata.json b/my-blog/static/client-metadata.json index 7d7a39c..37a408d 100644 --- a/my-blog/static/client-metadata.json +++ b/my-blog/static/client-metadata.json @@ -1,6 +1,6 @@ { "client_id": "https://syui.ai/client-metadata.json", - "client_name": "ai.card", + "client_name": "ai.log", "client_uri": "https://syui.ai", "logo_uri": "https://syui.ai/favicon.ico", "tos_uri": "https://syui.ai/terms", @@ -21,4 +21,4 @@ "subject_type": "public", "application_type": "web", "dpop_bound_access_tokens": true -} \ No newline at end of file +} diff --git a/oauth/.env.production b/oauth/.env.production index 3db4198..e7e8431 100644 --- a/oauth/.env.production +++ b/oauth/.env.production @@ -9,7 +9,7 @@ VITE_ADMIN_HANDLE=ai.syui.ai VITE_AI_HANDLE=ai.syui.ai VITE_OAUTH_COLLECTION=ai.syui.log VITE_ATPROTO_WEB_URL=https://bsky.app -VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","yui.syui.ai","syui.syu.is","ai.syu.is"] +VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"] # AI Configuration VITE_AI_ENABLED=true diff --git a/oauth/package.json b/oauth/package.json index 40ef292..320fa3c 100644 --- a/oauth/package.json +++ b/oauth/package.json @@ -7,7 +7,9 @@ "build": "vite build --mode production", "build:dev": "vite build --mode development", "build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development", - "preview": "vite preview" + "preview": "npm run test:console && vite preview", + "test": "vitest", + "test:console": "node -r esbuild-register src/tests/console-test.ts" }, "dependencies": { "@atproto/api": "^0.15.12", @@ -26,6 +28,9 @@ "@types/react-dom": "^18.2.18", "@vitejs/plugin-react": "^4.2.1", "typescript": "^5.3.3", - "vite": "^5.0.10" + "vite": "^5.0.10", + "vitest": "^1.1.0", + "esbuild": "^0.19.10", + "esbuild-register": "^3.5.0" } } diff --git a/oauth/public/client-metadata.json b/oauth/public/client-metadata.json index 8af8db1..37a408d 100644 --- a/oauth/public/client-metadata.json +++ b/oauth/public/client-metadata.json @@ -1,13 +1,13 @@ { - "client_id": "https://log.syui.ai/client-metadata.json", - "client_name": "ai.card", - "client_uri": "https://log.syui.ai", - "logo_uri": "https://log.syui.ai/favicon.ico", - "tos_uri": "https://log.syui.ai/terms", - "policy_uri": "https://log.syui.ai/privacy", + "client_id": "https://syui.ai/client-metadata.json", + "client_name": "ai.log", + "client_uri": "https://syui.ai", + "logo_uri": "https://syui.ai/favicon.ico", + "tos_uri": "https://syui.ai/terms", + "policy_uri": "https://syui.ai/privacy", "redirect_uris": [ - "https://log.syui.ai/oauth/callback", - "https://log.syui.ai/" + "https://syui.ai/oauth/callback", + "https://syui.ai/" ], "response_types": [ "code" @@ -21,4 +21,4 @@ "subject_type": "public", "application_type": "web", "dpop_bound_access_tokens": true -} \ No newline at end of file +} diff --git a/oauth/src/App.tsx b/oauth/src/App.tsx index 5f9c505..cd62d80 100644 --- a/oauth/src/App.tsx +++ b/oauth/src/App.tsx @@ -5,6 +5,7 @@ import { authService, User } from './services/auth'; import { atprotoOAuthService } from './services/atproto-oauth'; import { appConfig, getCollectionNames } from './config/app'; import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection'; +import { isValidDid } from './utils/validation'; import './App.css'; function App() { @@ -338,16 +339,23 @@ function App() { const currentAdminDid = adminDid || appConfig.adminDid; // Don't proceed if we don't have a valid DID - if (!currentAdminDid) { + if (!currentAdminDid || !isValidDid(currentAdminDid)) { return; } - // Use admin's PDS from config - const adminConfig = getNetworkConfig(appConfig.atprotoPds); - const collections = getCollectionNames(appConfig.collections.base); + // Resolve admin's actual PDS from their DID + let adminPdsEndpoint; + try { + const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); + const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); + adminPdsEndpoint = config.pdsApi; + } catch { + // Fallback to configured PDS + const adminConfig = getNetworkConfig(appConfig.atprotoPds); + adminPdsEndpoint = adminConfig.pdsApi; + } - // First, get user list from admin using their proper PDS - const adminPdsEndpoint = adminConfig.pdsApi; + const collections = getCollectionNames(appConfig.collections.base); const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`); @@ -383,6 +391,10 @@ function App() { // Use per-user PDS detection for each user's chat records let userPdsEndpoint; try { + // Validate DID format before making API calls + if (!userDid || !userDid.startsWith('did:')) { + continue; + } const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); userPdsEndpoint = config.pdsApi; @@ -396,6 +408,9 @@ function App() { const chatData = await chatResponse.json(); const records = chatData.records || []; allChatRecords.push(...records); + } else if (chatResponse.status === 400) { + // Skip 400 errors (repo not found, etc) + continue; } } catch (err) { continue; @@ -443,13 +458,21 @@ function App() { const currentAdminDid = adminDid || appConfig.adminDid; // Don't proceed if we don't have a valid DID - if (!currentAdminDid) { + if (!currentAdminDid || !isValidDid(currentAdminDid)) { return; } - // Use admin's PDS for collection access (from config) - const adminConfig = getNetworkConfig(appConfig.atprotoPds); - const atprotoApi = adminConfig.pdsApi; + // Resolve admin's actual PDS from their DID + let atprotoApi; + try { + const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); + const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); + atprotoApi = config.pdsApi; + } catch { + // Fallback to configured PDS + const adminConfig = getNetworkConfig(appConfig.atprotoPds); + atprotoApi = adminConfig.pdsApi; + } const collections = getCollectionNames(appConfig.collections.base); // Load lang:en records diff --git a/oauth/src/services/atproto-oauth.ts b/oauth/src/services/atproto-oauth.ts index f5b71ab..21407da 100644 --- a/oauth/src/services/atproto-oauth.ts +++ b/oauth/src/services/atproto-oauth.ts @@ -204,25 +204,47 @@ class AtprotoOAuthService { return `${origin}/client-metadata.json`; } - private detectPDSFromHandle(handle: string): string { - + private async detectPDSFromHandle(handle: string): Promise { + // Handle detection for OAuth PDS routing - // Supported PDS hosts and their corresponding handles + // Check if handle ends with known PDS domains first const pdsMapping = { 'syu.is': 'https://syu.is', 'bsky.social': 'https://bsky.social', }; - // Check if handle ends with known PDS domains for (const [domain, pdsUrl] of Object.entries(pdsMapping)) { if (handle.endsWith(`.${domain}`)) { - + // Using PDS for domain match return pdsUrl; } } + // For handles that don't match domain patterns, resolve via API + try { + // Try to resolve handle to get the actual PDS + const endpoints = ['https://syu.is', 'https://bsky.social']; + + for (const endpoint of endpoints) { + try { + const response = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); + if (response.ok) { + const data = await response.json(); + if (data.did) { + console.log('[OAuth Debug] Resolved handle via', endpoint, '- using that PDS'); + return endpoint; + } + } + } catch (e) { + continue; + } + } + } catch (e) { + console.log('[OAuth Debug] Handle resolution failed, using default'); + } + // Default to bsky.social - + // Using default bsky.social return 'https://bsky.social'; } @@ -250,41 +272,53 @@ class AtprotoOAuthService { // Detect PDS based on handle - const pdsUrl = this.detectPDSFromHandle(handle); + const pdsUrl = await this.detectPDSFromHandle(handle); + // Starting OAuth flow - // Re-initialize OAuth client with correct PDS if needed - if (pdsUrl !== 'https://bsky.social') { - - this.oauthClient = await BrowserOAuthClient.load({ - clientId: this.getClientId(), - handleResolver: pdsUrl, - }); - } + // Always re-initialize OAuth client with detected PDS + // Re-initializing OAuth client + + // Clear existing client to force fresh initialization + this.oauthClient = null; + this.initializePromise = null; + + this.oauthClient = await BrowserOAuthClient.load({ + clientId: this.getClientId(), + handleResolver: pdsUrl, + }); + + // OAuth client initialized // Start OAuth authorization flow try { - const authUrl = await this.oauthClient.authorize(handle, { + // Starting OAuth authorization + + // Try to authorize with DID instead of handle for syu.is PDS only + let authTarget = handle; + if (pdsUrl === 'https://syu.is') { + try { + const resolveResponse = await fetch(`${pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`); + if (resolveResponse.ok) { + const resolveData = await resolveResponse.json(); + authTarget = resolveData.did; + // Using DID for syu.is OAuth workaround + } + } catch (e) { + // Could not resolve to DID, using handle + } + } + + const authUrl = await this.oauthClient.authorize(authTarget, { scope: 'atproto transition:generic', }); - - - - // Store some debug info before redirect - sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({ - timestamp: new Date().toISOString(), - handle: handle, - authUrl: authUrl.toString(), - currentUrl: window.location.href - })); // Redirect to authorization server - window.location.href = authUrl.toString(); } catch (authorizeError) { - + // Authorization failed throw authorizeError; } diff --git a/oauth/src/tests/console-test.ts b/oauth/src/tests/console-test.ts new file mode 100644 index 0000000..ea02692 --- /dev/null +++ b/oauth/src/tests/console-test.ts @@ -0,0 +1,135 @@ +// Simple console test for OAuth app +// This runs before 'npm run preview' to display test results + +// Mock import.meta.env for Node.js environment +(global as any).import = { + meta: { + env: { + VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is', + VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai', + VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai', + VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log', + VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]', + VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai' + } + } +}; + +// Simple implementation of functions for testing +function detectPdsFromHandle(handle: string): string { + if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) { + return 'syu.is'; + } + if (handle.endsWith('.bsky.social')) { + return 'bsky.social'; + } + // Default case - check if it's in the allowed list + const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]'); + if (allowedHandles.includes(handle)) { + return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is'; + } + return 'bsky.social'; +} + +function getNetworkConfig(pds: string) { + switch (pds) { + case 'bsky.social': + case 'bsky.app': + return { + pdsApi: `https://${pds}`, + plcApi: 'https://plc.directory', + bskyApi: 'https://public.api.bsky.app', + webUrl: 'https://bsky.app' + }; + case 'syu.is': + return { + pdsApi: 'https://syu.is', + plcApi: 'https://plc.syu.is', + bskyApi: 'https://bsky.syu.is', + webUrl: 'https://web.syu.is' + }; + default: + return { + pdsApi: `https://${pds}`, + plcApi: 'https://plc.directory', + bskyApi: 'https://public.api.bsky.app', + webUrl: 'https://bsky.app' + }; + } +} + +// Main test execution +console.log('\n=== OAuth App Configuration Tests ===\n'); + +// Test 1: Handle input behavior +console.log('1. Handle Input → PDS Detection:'); +const testHandles = [ + 'syui.ai', + 'syui.syu.is', + 'syui.syui.ai', + 'test.bsky.social', + 'unknown.handle' +]; + +testHandles.forEach(handle => { + const pds = detectPdsFromHandle(handle); + const config = getNetworkConfig(pds); + console.log(` ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`); +}); + +// Test 2: Environment variable impact +console.log('\n2. Current Environment Configuration:'); +const env = (global as any).import.meta.env; +console.log(` VITE_ATPROTO_PDS: ${env.VITE_ATPROTO_PDS}`); +console.log(` VITE_ADMIN_HANDLE: ${env.VITE_ADMIN_HANDLE}`); +console.log(` VITE_AI_HANDLE: ${env.VITE_AI_HANDLE}`); +console.log(` VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`); +console.log(` VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`); + +// Test 3: API endpoint generation +console.log('\n3. Generated API Endpoints:'); +const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE); +const adminConfig = getNetworkConfig(adminPds); +console.log(` Admin PDS detection: ${env.VITE_ADMIN_HANDLE} → ${adminPds}`); +console.log(` Admin API endpoints:`); +console.log(` - PDS API: ${adminConfig.pdsApi}`); +console.log(` - Bsky API: ${adminConfig.bskyApi}`); +console.log(` - Web URL: ${adminConfig.webUrl}`); + +// Test 4: Collection URLs +console.log('\n4. Collection API URLs:'); +const baseCollection = env.VITE_OAUTH_COLLECTION; +console.log(` User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`); +console.log(` Chat: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`); +console.log(` Lang: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`); +console.log(` Comment: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`); + +// Test 5: OAuth routing logic +console.log('\n5. OAuth Authorization Logic:'); +const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]'); +console.log(` Allowed handles: ${JSON.stringify(allowedHandles)}`); +console.log(` OAuth scenarios:`); + +const oauthTestCases = [ + 'syui.ai', // Should use syu.is (in allowed list) + 'test.syu.is', // Should use syu.is (*.syu.is pattern) + 'user.bsky.social' // Should use bsky.social (default) +]; + +oauthTestCases.forEach(handle => { + const pds = detectPdsFromHandle(handle); + const isAllowed = allowedHandles.includes(handle); + const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' : + isAllowed ? 'in allowed list' : + 'default'; + console.log(` ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`); +}); + +// Test 6: AI Profile Resolution +console.log('\n6. AI Profile Resolution:'); +const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE); +const aiConfig = getNetworkConfig(aiPds); +console.log(` AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`); +console.log(` AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`); + +console.log('\n=== Tests Complete ===\n'); \ No newline at end of file diff --git a/oauth/src/tests/oauth.test.ts b/oauth/src/tests/oauth.test.ts new file mode 100644 index 0000000..82b708a --- /dev/null +++ b/oauth/src/tests/oauth.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getAppConfig } from '../config/app'; +import { detectPdsFromHandle, getNetworkConfig } from '../App'; + +// Test helper to mock environment variables +const mockEnv = (vars: Record) => { + Object.keys(vars).forEach(key => { + (import.meta.env as any)[key] = vars[key]; + }); +}; + +describe('OAuth App Tests', () => { + describe('Handle Input Behavior', () => { + it('should detect PDS for syui.ai (Bluesky)', () => { + const pds = detectPdsFromHandle('syui.ai'); + expect(pds).toBe('bsky.social'); + }); + + it('should detect PDS for syui.syu.is (syu.is)', () => { + const pds = detectPdsFromHandle('syui.syu.is'); + expect(pds).toBe('syu.is'); + }); + + it('should detect PDS for syui.syui.ai (syu.is)', () => { + const pds = detectPdsFromHandle('syui.syui.ai'); + expect(pds).toBe('syu.is'); + }); + + it('should use network config for different PDS', () => { + const bskyConfig = getNetworkConfig('bsky.social'); + expect(bskyConfig.pdsApi).toBe('https://bsky.social'); + expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app'); + expect(bskyConfig.webUrl).toBe('https://bsky.app'); + + const syuisConfig = getNetworkConfig('syu.is'); + expect(syuisConfig.pdsApi).toBe('https://syu.is'); + expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is'); + expect(syuisConfig.webUrl).toBe('https://web.syu.is'); + }); + }); + + describe('Environment Variable Changes', () => { + beforeEach(() => { + // Reset environment variables + delete (import.meta.env as any).VITE_ATPROTO_PDS; + delete (import.meta.env as any).VITE_ADMIN_HANDLE; + delete (import.meta.env as any).VITE_AI_HANDLE; + }); + + it('should use correct PDS for AI profile', () => { + mockEnv({ + VITE_ATPROTO_PDS: 'syu.is', + VITE_ADMIN_HANDLE: 'ai.syui.ai', + VITE_AI_HANDLE: 'ai.syui.ai' + }); + + const config = getAppConfig(); + expect(config.atprotoPds).toBe('syu.is'); + expect(config.adminHandle).toBe('ai.syui.ai'); + expect(config.aiHandle).toBe('ai.syui.ai'); + + // Network config should use syu.is endpoints + const networkConfig = getNetworkConfig(config.atprotoPds); + expect(networkConfig.bskyApi).toBe('https://bsky.syu.is'); + }); + + it('should construct correct API requests for admin userlist', () => { + mockEnv({ + VITE_ATPROTO_PDS: 'syu.is', + VITE_ADMIN_HANDLE: 'ai.syui.ai', + VITE_OAUTH_COLLECTION: 'ai.syui.log' + }); + + const config = getAppConfig(); + const networkConfig = getNetworkConfig(config.atprotoPds); + const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`; + + expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user'); + }); + }); + + describe('OAuth Login Flow', () => { + it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => { + mockEnv({ + VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]', + VITE_ATPROTO_PDS: 'syu.is' + }); + + const config = getAppConfig(); + const handle = 'syui.ai'; + + // Check if handle is in allowed list + expect(config.allowedHandles).toContain(handle); + + // Should use configured PDS for OAuth + const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`; + expect(expectedAuthUrl).toContain('syu.is'); + }); + + it('should use syu.is OAuth for *.syu.is handles', () => { + const handle = 'test.syu.is'; + const pds = detectPdsFromHandle(handle); + expect(pds).toBe('syu.is'); + }); + }); +}); + +// Terminal display test output +export function runTerminalTests() { + console.log('\n=== OAuth App Tests ===\n'); + + // Test 1: Handle input behavior + console.log('1. Handle Input Detection:'); + const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai']; + handles.forEach(handle => { + const pds = detectPdsFromHandle(handle); + console.log(` ${handle} → PDS: ${pds}`); + }); + + // Test 2: Environment variable impact + console.log('\n2. Environment Variables:'); + const config = getAppConfig(); + console.log(` VITE_ATPROTO_PDS: ${config.atprotoPds}`); + console.log(` VITE_ADMIN_HANDLE: ${config.adminHandle}`); + console.log(` VITE_AI_HANDLE: ${config.aiHandle}`); + console.log(` VITE_OAUTH_COLLECTION: ${config.collections.base}`); + + // Test 3: API endpoints + console.log('\n3. API Endpoints:'); + const networkConfig = getNetworkConfig(config.atprotoPds); + console.log(` Admin PDS API: ${networkConfig.pdsApi}`); + console.log(` Admin Bsky API: ${networkConfig.bskyApi}`); + console.log(` User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`); + + // Test 4: OAuth routing + console.log('\n4. OAuth Routing:'); + console.log(` Allowed handles: ${JSON.stringify(config.allowedHandles)}`); + console.log(` OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`); + + console.log('\n=== End Tests ===\n'); +} \ No newline at end of file diff --git a/oauth/src/utils/pds-detection.ts b/oauth/src/utils/pds-detection.ts index a669ab3..74ad646 100644 --- a/oauth/src/utils/pds-detection.ts +++ b/oauth/src/utils/pds-detection.ts @@ -1,5 +1,7 @@ // PDS Detection and API URL mapping utilities +import { isValidDid, isValidHandle } from './validation'; + export interface NetworkConfig { pdsApi: string; plcApi: string; @@ -9,12 +11,33 @@ export interface NetworkConfig { // Detect PDS from handle export function detectPdsFromHandle(handle: string): string { - if (handle.endsWith('.syu.is')) { + // 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'; } @@ -74,8 +97,13 @@ export function getApiUrlForUser(handle: string): string { return config.bskyApi; } -// Resolve handle/DID to actual PDS endpoint using com.atproto.repo.describeRepo +// 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; @@ -83,7 +111,7 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st if (!handleOrDid.startsWith('did:')) { try { // Try multiple endpoints for handle resolution - const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is']; + const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is']; let resolved = false; for (const endpoint of resolveEndpoints) { @@ -108,7 +136,34 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st } } - // Now use com.atproto.repo.describeRepo to get PDS from known PDS endpoints + // 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) { diff --git a/oauth/src/utils/validation.ts b/oauth/src/utils/validation.ts new file mode 100644 index 0000000..dc5b8d8 --- /dev/null +++ b/oauth/src/utils/validation.ts @@ -0,0 +1,21 @@ +// 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); +} \ No newline at end of file diff --git a/scpt/run.zsh b/scpt/run.zsh index 27c9ff5..e47d5e2 100755 --- a/scpt/run.zsh +++ b/scpt/run.zsh @@ -6,7 +6,7 @@ function _env() { oauth=$d/oauth myblog=$d/my-blog port=4173 - source $oauth/.env.production + #source $oauth/.env.production case $OSTYPE in darwin*) export NVM_DIR="$HOME/.nvm" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a32bd75 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +// Export modules for testing +pub mod ai; +pub mod analyzer; +pub mod atproto; +pub mod commands; +pub mod config; +pub mod doc_generator; +pub mod generator; +pub mod markdown; +pub mod mcp; +pub mod oauth; +// pub mod ollama_proxy; // Temporarily disabled - uses actix-web instead of axum +pub mod template; +pub mod translator; \ No newline at end of file diff --git a/src/translator/markdown_parser.rs b/src/translator/markdown_parser.rs index d6aa3b9..e212ebb 100644 --- a/src/translator/markdown_parser.rs +++ b/src/translator/markdown_parser.rs @@ -2,6 +2,7 @@ use anyhow::Result; use regex::Regex; use super::MarkdownSection; +#[derive(Clone)] pub struct MarkdownParser { _code_block_regex: Regex, header_regex: Regex, diff --git a/src/translator/mod.rs b/src/translator/mod.rs index 2643e31..6304caa 100644 --- a/src/translator/mod.rs +++ b/src/translator/mod.rs @@ -42,9 +42,9 @@ pub enum MarkdownSection { pub trait Translator { #[allow(dead_code)] - async fn translate(&self, content: &str, config: &TranslationConfig) -> Result; - async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result; - async fn translate_sections(&self, sections: Vec, config: &TranslationConfig) -> Result>; + fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future> + Send; + fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future> + Send; + fn translate_sections(&self, sections: Vec, config: &TranslationConfig) -> impl std::future::Future>> + Send; } #[allow(dead_code)] @@ -67,6 +67,7 @@ pub struct TranslationMetrics { pub sections_preserved: usize, } +#[derive(Clone)] pub struct LanguageMapping { pub mappings: HashMap, } diff --git a/src/translator/ollama_translator.rs b/src/translator/ollama_translator.rs index 25fbc45..57a2b05 100644 --- a/src/translator/ollama_translator.rs +++ b/src/translator/ollama_translator.rs @@ -5,6 +5,7 @@ use std::time::Instant; use super::*; use crate::translator::markdown_parser::MarkdownParser; +#[derive(Clone)] pub struct OllamaTranslator { client: Client, language_mapping: LanguageMapping, @@ -129,86 +130,103 @@ Translation:"#, } impl Translator for OllamaTranslator { - async fn translate(&self, content: &str, config: &TranslationConfig) -> Result { - let prompt = self.build_translation_prompt(content, config)?; - self.call_ollama(&prompt, config).await + fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future> + Send { + async move { + let prompt = self.build_translation_prompt(content, config)?; + self.call_ollama(&prompt, config).await + } } - async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result { - println!("🔄 Parsing markdown content..."); - let sections = self.parser.parse_markdown(content)?; - - println!("📝 Found {} sections to process", sections.len()); - let translated_sections = self.translate_sections(sections, config).await?; - - println!("✅ Rebuilding markdown from translated sections..."); - let result = self.parser.rebuild_markdown(translated_sections); - - Ok(result) - } - - async fn translate_sections(&self, sections: Vec, config: &TranslationConfig) -> Result> { - let mut translated_sections = Vec::new(); - let start_time = Instant::now(); - - for (index, section) in sections.into_iter().enumerate() { - println!(" 🔤 Processing section {}", index + 1); + fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future> + Send { + async move { + println!("🔄 Parsing markdown content..."); + let sections = self.parser.parse_markdown(content)?; - let translated_section = match §ion { - MarkdownSection::Code(_content, _lang) => { - if config.preserve_code { - println!(" ⏭️ Preserving code block"); - section // Preserve code blocks - } else { - section // Still preserve for now - } - } - MarkdownSection::Link(text, url) => { - if config.preserve_links { - println!(" ⏭️ Preserving link"); - section // Preserve links - } else { - // Translate link text only - let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), config)?; - let translated_text = self.call_ollama(&prompt, config).await?; - MarkdownSection::Link(translated_text.trim().to_string(), url.clone()) - } - } - MarkdownSection::Image(_alt, _url) => { - println!(" 🖼️ Preserving image"); - section // Preserve images - } - MarkdownSection::Table(content) => { - println!(" 📊 Translating table content"); - let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), config)?; - let translated_content = self.call_ollama(&prompt, config).await?; - MarkdownSection::Table(translated_content.trim().to_string()) - } - _ => { - // Translate text sections - println!(" 🔤 Translating text"); - let prompt = self.build_section_translation_prompt(§ion, config)?; - let translated_text = self.call_ollama(&prompt, config).await?; - - match section { - MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()), - MarkdownSection::Header(_, level) => MarkdownSection::Header(translated_text.trim().to_string(), level), - MarkdownSection::Quote(_) => MarkdownSection::Quote(translated_text.trim().to_string()), - MarkdownSection::List(_) => MarkdownSection::List(translated_text.trim().to_string()), - _ => section, - } - } + println!("📝 Found {} sections to process", sections.len()); + let translated_sections = self.translate_sections(sections, config).await?; + + println!("✅ Rebuilding markdown from translated sections..."); + let result = self.parser.rebuild_markdown(translated_sections); + + Ok(result) + } + } + + fn translate_sections(&self, sections: Vec, config: &TranslationConfig) -> impl std::future::Future>> + Send { + let config = config.clone(); + let client = self.client.clone(); + let parser = self.parser.clone(); + let language_mapping = self.language_mapping.clone(); + + async move { + let translator = OllamaTranslator { + client, + language_mapping, + parser, }; - translated_sections.push(translated_section); - - // Add small delay to avoid overwhelming Ollama - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + let mut translated_sections = Vec::new(); + let start_time = Instant::now(); + + for (index, section) in sections.into_iter().enumerate() { + println!(" 🔤 Processing section {}", index + 1); + + let translated_section = match §ion { + MarkdownSection::Code(_content, _lang) => { + if config.preserve_code { + println!(" ⏭️ Preserving code block"); + section // Preserve code blocks + } else { + section // Still preserve for now + } + } + MarkdownSection::Link(text, url) => { + if config.preserve_links { + println!(" ⏭️ Preserving link"); + section // Preserve links + } else { + // Translate link text only + let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), &config)?; + let translated_text = translator.call_ollama(&prompt, &config).await?; + MarkdownSection::Link(translated_text.trim().to_string(), url.clone()) + } + } + MarkdownSection::Image(_alt, _url) => { + println!(" 🖼️ Preserving image"); + section // Preserve images + } + MarkdownSection::Table(content) => { + println!(" 📊 Translating table content"); + let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), &config)?; + let translated_content = translator.call_ollama(&prompt, &config).await?; + MarkdownSection::Table(translated_content.trim().to_string()) + } + _ => { + // Translate text sections + println!(" 🔤 Translating text"); + let prompt = translator.build_section_translation_prompt(§ion, &config)?; + let translated_text = translator.call_ollama(&prompt, &config).await?; + + match section { + MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()), + MarkdownSection::Header(_, level) => MarkdownSection::Header(translated_text.trim().to_string(), level), + MarkdownSection::Quote(_) => MarkdownSection::Quote(translated_text.trim().to_string()), + MarkdownSection::List(_) => MarkdownSection::List(translated_text.trim().to_string()), + _ => section, + } + } + }; + + translated_sections.push(translated_section); + + // Add small delay to avoid overwhelming Ollama + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + let elapsed = start_time.elapsed(); + println!("⏱️ Translation completed in {:.2}s", elapsed.as_secs_f64()); + + Ok(translated_sections) } - - let elapsed = start_time.elapsed(); - println!("⏱️ Translation completed in {:.2}s", elapsed.as_secs_f64()); - - Ok(translated_sections) } } \ No newline at end of file