Compare commits
4 Commits
v0.2.1
...
test-oauth
Author | SHA1 | Date | |
---|---|---|---|
4edde5293a
|
|||
f0fdf678c8
|
|||
820e47f634
|
|||
4dac4a83e0
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,3 +17,4 @@ my-blog/templates/oauth-assets.html
|
||||
cloudflared-config.yml
|
||||
.config
|
||||
oauth-server-example
|
||||
atproto
|
||||
|
Binary file not shown.
@ -16,8 +16,10 @@ auto_translate = false
|
||||
comment_moderation = false
|
||||
ask_ai = true
|
||||
provider = "ollama"
|
||||
model = "gemma3:4b"
|
||||
host = "https://localhost:11434"
|
||||
model = "qwen3"
|
||||
model_translation = "llama3.2:1b"
|
||||
model_technical = "phi3:mini"
|
||||
host = "http://localhost:11434"
|
||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
handle = "ai.syui.ai"
|
||||
#num_predict = 200
|
||||
|
@ -16,5 +16,5 @@ VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:4b
|
||||
VITE_AI_HOST=https://localhost:11434
|
||||
VITE_AI_HOST=http://localhost:11434
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
|
@ -15,7 +15,7 @@ VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"]
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:4b
|
||||
VITE_AI_MODEL=gemma3:1b
|
||||
VITE_AI_HOST=https://ollama.syui.ai
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
|
||||
|
@ -224,13 +224,7 @@ function App() {
|
||||
// Ensure handle is not DID
|
||||
const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
|
||||
|
||||
// Check if handle is allowed
|
||||
if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(handle)) {
|
||||
// Handle not in allowed list
|
||||
setError(`Access denied: ${handle} is not authorized for this application.`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Note: appConfig.allowedHandles is used for PDS detection, not access control
|
||||
|
||||
// Get user profile including avatar
|
||||
const userProfile = await getUserProfile(oauthResult.did, handle);
|
||||
@ -550,6 +544,13 @@ function App() {
|
||||
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||||
if (profileResponse.ok) {
|
||||
const profileData = await profileResponse.json();
|
||||
|
||||
// Determine correct web URL based on avatar source
|
||||
let webUrl = config.webUrl; // Default from handle-based detection
|
||||
if (profileData.avatar && profileData.avatar.includes('cdn.bsky.app')) {
|
||||
webUrl = 'https://bsky.app'; // Override to Bluesky if avatar is from Bluesky
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
value: {
|
||||
@ -559,7 +560,7 @@ function App() {
|
||||
avatar: profileData.avatar,
|
||||
displayName: profileData.displayName || handle,
|
||||
_pdsEndpoint: `https://${pds}`, // Store PDS info for later use
|
||||
_webUrl: config.webUrl, // Store web URL for profile links
|
||||
_webUrl: webUrl, // Store corrected web URL for profile links
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -810,6 +811,14 @@ function App() {
|
||||
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(record.value.author.handle));
|
||||
|
||||
if (profile) {
|
||||
// Determine network config based on profile data
|
||||
let webUrl = 'https://bsky.app'; // Default to Bluesky
|
||||
if (profile.avatar && profile.avatar.includes('cdn.bsky.app')) {
|
||||
webUrl = 'https://bsky.app';
|
||||
} else if (profile.avatar && profile.avatar.includes('bsky.syu.is')) {
|
||||
webUrl = 'https://web.syu.is';
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
value: {
|
||||
@ -818,6 +827,7 @@ function App() {
|
||||
...record.value.author,
|
||||
avatar: profile.avatar,
|
||||
displayName: profile.displayName || record.value.author.handle,
|
||||
_webUrl: webUrl, // Store network config for profile URL generation
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1146,7 +1156,18 @@ function App() {
|
||||
return `${config.webUrl}/profile/${author.did}`;
|
||||
}
|
||||
|
||||
// Get PDS from handle for other users
|
||||
// For other users, detect network based on avatar URL or stored network info
|
||||
if (author.avatar && author.avatar.includes('cdn.bsky.app')) {
|
||||
// User has Bluesky avatar, use Bluesky web interface
|
||||
return `https://bsky.app/profile/${author.did}`;
|
||||
}
|
||||
|
||||
// Check if we have stored network config from profile fetching
|
||||
if (author._webUrl) {
|
||||
return `${author._webUrl}/profile/${author.did}`;
|
||||
}
|
||||
|
||||
// Fallback: Get PDS from handle for other users
|
||||
const pds = detectPdsFromHandle(author.handle);
|
||||
const config = getNetworkConfig(pds);
|
||||
|
||||
@ -1186,8 +1207,9 @@ function App() {
|
||||
|
||||
// Extract content based on format
|
||||
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
|
||||
// For AI comments, always use the loaded AI profile instead of record.value.author
|
||||
const authorInfo = aiProfile;
|
||||
// Use the author from the record if available, otherwise fall back to AI profile
|
||||
const authorInfo = value.author || aiProfile;
|
||||
|
||||
const postInfo = isNewFormat ? value.post : null;
|
||||
const contentType = value.type || 'unknown';
|
||||
const createdAt = value.createdAt || value.generated_at || '';
|
||||
|
@ -191,6 +191,7 @@ Answer:`;
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://syui.ai',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
|
@ -113,7 +113,7 @@ export function getAppConfig(): AppConfig {
|
||||
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
|
||||
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
|
||||
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
|
||||
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
|
||||
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma3:4b';
|
||||
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
|
||||
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
|
||||
const atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||
|
@ -12,6 +12,7 @@ interface AtprotoSession {
|
||||
|
||||
class AtprotoOAuthService {
|
||||
private oauthClient: BrowserOAuthClient | null = null;
|
||||
private oauthClientSyuIs: BrowserOAuthClient | null = null;
|
||||
private agent: Agent | null = null;
|
||||
private initializePromise: Promise<void> | null = null;
|
||||
|
||||
@ -31,22 +32,27 @@ class AtprotoOAuthService {
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
|
||||
|
||||
// Generate client ID based on current origin
|
||||
const clientId = this.getClientId();
|
||||
|
||||
|
||||
// Support multiple PDS hosts for OAuth
|
||||
// Initialize both OAuth clients
|
||||
this.oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: clientId,
|
||||
handleResolver: 'https://bsky.social', // Default resolver
|
||||
handleResolver: 'https://bsky.social',
|
||||
plcDirectoryUrl: 'https://plc.directory',
|
||||
});
|
||||
|
||||
this.oauthClientSyuIs = await BrowserOAuthClient.load({
|
||||
clientId: clientId,
|
||||
handleResolver: 'https://syu.is',
|
||||
plcDirectoryUrl: 'https://plc.syu.is',
|
||||
});
|
||||
|
||||
|
||||
// Try to restore existing session
|
||||
const result = await this.oauthClient.init();
|
||||
// Try to restore existing session from either client
|
||||
let result = await this.oauthClient.init();
|
||||
if (!result?.session) {
|
||||
result = await this.oauthClientSyuIs.init();
|
||||
}
|
||||
if (result?.session) {
|
||||
|
||||
// Create Agent instance with proper configuration
|
||||
@ -92,41 +98,13 @@ class AtprotoOAuthService {
|
||||
}
|
||||
|
||||
private async processSession(session: any): Promise<{ did: string; handle: string }> {
|
||||
|
||||
|
||||
// Log full session structure
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Check if agent has properties we can access
|
||||
if (session.agent) {
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
const did = session.sub || session.did;
|
||||
let handle = session.handle || 'unknown';
|
||||
|
||||
// Create Agent directly with session (per official docs)
|
||||
try {
|
||||
this.agent = new Agent(session);
|
||||
|
||||
|
||||
// Check if agent has session info after creation
|
||||
|
||||
|
||||
|
||||
if (this.agent.session) {
|
||||
|
||||
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
// Fallback to dpopFetch method
|
||||
this.agent = new Agent({
|
||||
service: session.server?.serviceEndpoint || 'https://bsky.social',
|
||||
@ -204,61 +182,15 @@ class AtprotoOAuthService {
|
||||
return `${origin}/client-metadata.json`;
|
||||
}
|
||||
|
||||
private async detectPDSFromHandle(handle: string): Promise<string> {
|
||||
// Handle detection for OAuth PDS routing
|
||||
|
||||
// Check if handle ends with known PDS domains first
|
||||
const pdsMapping = {
|
||||
'syu.is': 'https://syu.is',
|
||||
'bsky.social': 'https://bsky.social',
|
||||
};
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
async initiateOAuthFlow(handle?: string): Promise<void> {
|
||||
try {
|
||||
|
||||
|
||||
if (!this.oauthClient) {
|
||||
|
||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
throw new Error('Failed to initialize OAuth client');
|
||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
||||
throw new Error('Failed to initialize OAuth clients');
|
||||
}
|
||||
|
||||
// If handle is not provided, prompt user
|
||||
@ -269,61 +201,27 @@ class AtprotoOAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which OAuth client to use
|
||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
||||
let allowedHandles: string[] = [];
|
||||
try {
|
||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
||||
} catch {
|
||||
allowedHandles = [];
|
||||
}
|
||||
|
||||
|
||||
// Detect PDS based on handle
|
||||
const pdsUrl = await this.detectPDSFromHandle(handle);
|
||||
// Starting OAuth flow
|
||||
|
||||
|
||||
// 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
|
||||
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
|
||||
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
|
||||
|
||||
// Start OAuth authorization flow
|
||||
|
||||
|
||||
try {
|
||||
// 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, {
|
||||
const authUrl = await oauthClient.authorize(handle, {
|
||||
scope: 'atproto transition:generic',
|
||||
});
|
||||
|
||||
// Redirect to authorization server
|
||||
window.location.href = authUrl.toString();
|
||||
} catch (authorizeError) {
|
||||
// Authorization failed
|
||||
throw authorizeError;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
|
||||
}
|
||||
}
|
||||
@ -379,22 +277,16 @@ class AtprotoOAuthService {
|
||||
|
||||
async checkSession(): Promise<{ did: string; handle: string } | null> {
|
||||
try {
|
||||
|
||||
|
||||
if (!this.oauthClient) {
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const result = await this.oauthClient.init();
|
||||
|
||||
|
||||
if (result?.session) {
|
||||
// Use the common session processing method
|
||||
return this.processSession(result.session);
|
||||
@ -458,28 +350,20 @@ class AtprotoOAuthService {
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
|
||||
|
||||
// Clear Agent
|
||||
this.agent = null;
|
||||
|
||||
|
||||
// Clear BrowserOAuthClient session
|
||||
if (this.oauthClient) {
|
||||
|
||||
try {
|
||||
// BrowserOAuthClient may have a revoke or signOut method
|
||||
if (typeof (this.oauthClient as any).signOut === 'function') {
|
||||
await (this.oauthClient as any).signOut();
|
||||
|
||||
} else if (typeof (this.oauthClient as any).revoke === 'function') {
|
||||
await (this.oauthClient as any).revoke();
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
} catch (oauthError) {
|
||||
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
// Reset the OAuth client to force re-initialization
|
||||
@ -491,18 +375,16 @@ class AtprotoOAuthService {
|
||||
localStorage.removeItem('atproto_session');
|
||||
sessionStorage.clear();
|
||||
|
||||
// Clear all localStorage items that might be related to OAuth
|
||||
const keysToRemove: string[] = [];
|
||||
// Clear all OAuth-related storage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => {
|
||||
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear internal session info
|
||||
(this as any)._sessionInfo = null;
|
||||
|
||||
|
||||
|
||||
|
6
oauth_new/.env
Normal file
6
oauth_new/.env
Normal file
@ -0,0 +1,6 @@
|
||||
VITE_ADMIN=ai.syui.ai
|
||||
VITE_PDS=syu.is
|
||||
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
|
||||
VITE_COLLECTION=ai.syui.log
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
334
oauth_new/DEVELOPMENT.md
Normal file
334
oauth_new/DEVELOPMENT.md
Normal file
@ -0,0 +1,334 @@
|
||||
# 開発ガイド
|
||||
|
||||
## 設計思想
|
||||
|
||||
このプロジェクトは以下の原則に基づいて設計されています:
|
||||
|
||||
### 1. 環境変数による設定の外部化
|
||||
- ハードコードを避け、設定は全て環境変数で管理
|
||||
- `src/config/env.js` で一元管理
|
||||
|
||||
### 2. PDS(Personal Data Server)の自動判定
|
||||
- `VITE_HANDLE_LIST` と `VITE_PDS` による自動判定
|
||||
- syu.is系とbsky.social系の自動振り分け
|
||||
|
||||
### 3. コンポーネントの責任分離
|
||||
- Hooks: ビジネスロジック
|
||||
- Components: UI表示のみ
|
||||
- Services: 外部API連携
|
||||
- Utils: 純粋関数
|
||||
|
||||
## アーキテクチャ詳細
|
||||
|
||||
### データフロー
|
||||
|
||||
```
|
||||
User Input
|
||||
↓
|
||||
Hooks (useAuth, useAdminData, usePageContext)
|
||||
↓
|
||||
Services (OAuthService)
|
||||
↓
|
||||
API (atproto.js)
|
||||
↓
|
||||
ATProto Network
|
||||
↓
|
||||
Components (UI Display)
|
||||
```
|
||||
|
||||
### 状態管理
|
||||
|
||||
React Hooksによる状態管理:
|
||||
- `useAuth`: OAuth認証状態
|
||||
- `useAdminData`: 管理者データ(プロフィール、レコード)
|
||||
- `usePageContext`: ページ判定(トップ/個別)
|
||||
|
||||
### OAuth認証フロー
|
||||
|
||||
```
|
||||
1. ユーザーがハンドル入力
|
||||
2. PDS判定 (syu.is vs bsky.social)
|
||||
3. 適切なOAuthClientを選択
|
||||
4. 標準OAuth画面にリダイレクト
|
||||
5. 認証完了後コールバック処理
|
||||
6. セッション復元・保存
|
||||
```
|
||||
|
||||
## 重要な実装詳細
|
||||
|
||||
### セッション管理
|
||||
|
||||
`@atproto/oauth-client-browser`が自動的に以下を処理:
|
||||
- IndexedDBへのセッション保存
|
||||
- トークンの自動更新
|
||||
- DPoP(Demonstration of Proof of Possession)
|
||||
|
||||
**注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。
|
||||
|
||||
### PDS判定アルゴリズム
|
||||
|
||||
```javascript
|
||||
// src/utils/pds.js
|
||||
function isSyuIsHandle(handle) {
|
||||
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
|
||||
}
|
||||
```
|
||||
|
||||
1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is
|
||||
2. `.syu.is` で終わるハンドル → syu.is
|
||||
3. その他 → bsky.social
|
||||
|
||||
### レコードフィルタリング
|
||||
|
||||
```javascript
|
||||
// src/components/RecordTabs.jsx
|
||||
const filterRecords = (records) => {
|
||||
if (pageContext.isTopPage) {
|
||||
return records.slice(0, 3) // 最新3件
|
||||
} else {
|
||||
// URL のrkey と record.value.post.url のrkey を照合
|
||||
return records.filter(record => {
|
||||
const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '')
|
||||
return recordRkey === pageContext.rkey
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 開発時の注意点
|
||||
|
||||
### 1. 環境変数の命名
|
||||
|
||||
- `VITE_` プレフィックス必須(Viteの制約)
|
||||
- JSON形式の環境変数は文字列として定義
|
||||
|
||||
```bash
|
||||
# ❌ 間違い
|
||||
VITE_HANDLE_LIST=["ai.syui.ai"]
|
||||
|
||||
# ✅ 正しい
|
||||
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"]
|
||||
```
|
||||
|
||||
### 2. API エラーハンドリング
|
||||
|
||||
```javascript
|
||||
// src/api/atproto.js
|
||||
async function request(url) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
```
|
||||
|
||||
すべてのAPI呼び出しでエラーハンドリングを実装。
|
||||
|
||||
### 3. コンポーネント設計
|
||||
|
||||
```javascript
|
||||
// ❌ Bad: ビジネスロジックがコンポーネント内
|
||||
function MyComponent() {
|
||||
const [data, setData] = useState([])
|
||||
useEffect(() => {
|
||||
fetch('/api/data').then(setData)
|
||||
}, [])
|
||||
return <div>{data.map(...)}</div>
|
||||
}
|
||||
|
||||
// ✅ Good: Hooksでロジック分離
|
||||
function MyComponent() {
|
||||
const { data, loading, error } = useMyData()
|
||||
if (loading) return <Loading />
|
||||
if (error) return <Error />
|
||||
return <div>{data.map(...)}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## デバッグ手法
|
||||
|
||||
### 1. OAuth デバッグ
|
||||
|
||||
```javascript
|
||||
// ブラウザの開発者ツールで確認
|
||||
localStorage.clear() // セッションクリア
|
||||
sessionStorage.clear() // 一時データクリア
|
||||
|
||||
// IndexedDB確認(Application タブ)
|
||||
// ATProtoの認証データが保存される
|
||||
```
|
||||
|
||||
### 2. PDS判定デバッグ
|
||||
|
||||
```javascript
|
||||
// src/utils/pds.js にログ追加
|
||||
console.log('Handle:', handle)
|
||||
console.log('Is syu.is:', isSyuIsHandle(handle))
|
||||
console.log('API Config:', getApiConfig(pds))
|
||||
```
|
||||
|
||||
### 3. レコードフィルタリングデバッグ
|
||||
|
||||
```javascript
|
||||
// src/components/RecordTabs.jsx
|
||||
console.log('Page Context:', pageContext)
|
||||
console.log('All Records:', records.length)
|
||||
console.log('Filtered Records:', filteredRecords.length)
|
||||
```
|
||||
|
||||
## パフォーマンス最適化
|
||||
|
||||
### 1. 並列データ取得
|
||||
|
||||
```javascript
|
||||
// src/hooks/useAdminData.js
|
||||
const [records, lang, comment] = await Promise.all([
|
||||
collections.getBase(apiConfig.pds, did, env.collection),
|
||||
collections.getLang(apiConfig.pds, did, env.collection),
|
||||
collections.getComment(apiConfig.pds, did, env.collection)
|
||||
])
|
||||
```
|
||||
|
||||
### 2. 不要な再レンダリング防止
|
||||
|
||||
```javascript
|
||||
// useMemo でフィルタリング結果をキャッシュ
|
||||
const filteredRecords = useMemo(() =>
|
||||
filterRecords(records),
|
||||
[records, pageContext]
|
||||
)
|
||||
```
|
||||
|
||||
## テスト戦略
|
||||
|
||||
### 1. 単体テスト推奨対象
|
||||
|
||||
- `src/utils/pds.js` - PDS判定ロジック
|
||||
- `src/config/env.js` - 環境変数パース
|
||||
- フィルタリング関数
|
||||
|
||||
### 2. 統合テスト推奨対象
|
||||
|
||||
- OAuth認証フロー
|
||||
- API呼び出し
|
||||
- レコード表示
|
||||
|
||||
## デプロイメント
|
||||
|
||||
### 1. 必要ファイル
|
||||
|
||||
```
|
||||
public/
|
||||
└── client-metadata.json # OAuth設定ファイル
|
||||
|
||||
dist/ # ビルド出力
|
||||
├── index.html
|
||||
└── assets/
|
||||
├── comment-atproto-[hash].js
|
||||
└── comment-atproto-[hash].css
|
||||
```
|
||||
|
||||
### 2. デプロイ手順
|
||||
|
||||
```bash
|
||||
# 1. 環境変数設定
|
||||
cp .env.example .env
|
||||
# 2. 本番用設定を記入
|
||||
# 3. ビルド
|
||||
npm run build
|
||||
# 4. dist/ フォルダをデプロイ
|
||||
```
|
||||
|
||||
### 3. 本番環境チェックリスト
|
||||
|
||||
- [ ] `.env` ファイルの本番設定
|
||||
- [ ] `client-metadata.json` の設置
|
||||
- [ ] HTTPS 必須(OAuth要件)
|
||||
- [ ] CSP(Content Security Policy)設定
|
||||
|
||||
## よくある問題と解決法
|
||||
|
||||
### 1. "OAuth initialization failed"
|
||||
|
||||
**原因**: client-metadata.json が見つからない、または形式が正しくない
|
||||
|
||||
**解決法**:
|
||||
```bash
|
||||
# public/client-metadata.json の存在確認
|
||||
ls -la public/client-metadata.json
|
||||
|
||||
# 形式確認(JSON validation)
|
||||
jq . public/client-metadata.json
|
||||
```
|
||||
|
||||
### 2. "Failed to load admin data"
|
||||
|
||||
**原因**: 管理者アカウントのDID解決に失敗
|
||||
|
||||
**解決法**:
|
||||
```bash
|
||||
# 手動でDID解決確認
|
||||
curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai"
|
||||
```
|
||||
|
||||
### 3. レコードが表示されない
|
||||
|
||||
**原因**: コレクション名の不一致、権限不足
|
||||
|
||||
**解決法**:
|
||||
```bash
|
||||
# コレクション確認
|
||||
curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang"
|
||||
```
|
||||
|
||||
## 機能拡張ガイド
|
||||
|
||||
### 1. 新しいコレクション追加
|
||||
|
||||
```javascript
|
||||
// src/api/atproto.js に追加
|
||||
export const collections = {
|
||||
// 既存...
|
||||
async getNewCollection(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.new`, limit)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 新しいPDS対応
|
||||
|
||||
```javascript
|
||||
// src/utils/pds.js を拡張
|
||||
export function getApiConfig(pds) {
|
||||
if (pds.includes('syu.is')) {
|
||||
// 既存の syu.is 設定
|
||||
} else if (pds.includes('newpds.com')) {
|
||||
return {
|
||||
pds: `https://newpds.com`,
|
||||
bsky: `https://bsky.newpds.com`,
|
||||
plc: `https://plc.newpds.com`,
|
||||
web: `https://web.newpds.com`
|
||||
}
|
||||
}
|
||||
// デフォルト設定
|
||||
}
|
||||
```
|
||||
|
||||
### 3. リアルタイム更新追加
|
||||
|
||||
```javascript
|
||||
// src/hooks/useRealtimeUpdates.js
|
||||
export function useRealtimeUpdates(collection) {
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.collection === collection) {
|
||||
// 新しいレコードを追加
|
||||
}
|
||||
}
|
||||
return () => ws.close()
|
||||
}, [collection])
|
||||
}
|
||||
```
|
222
oauth_new/README.md
Normal file
222
oauth_new/README.md
Normal file
@ -0,0 +1,222 @@
|
||||
# ATProto OAuth Comment System
|
||||
|
||||
ATProtocol(Bluesky)のOAuth認証を使用したコメントシステムです。
|
||||
|
||||
## プロジェクト概要
|
||||
|
||||
このプロジェクトは、ATProtocolネットワーク上のコメントとlangレコードを表示するWebアプリケーションです。
|
||||
- 標準的なOAuth認証画面を使用
|
||||
- タブ切り替えでレコード表示
|
||||
- ページコンテキストに応じたフィルタリング
|
||||
|
||||
## ファイル構成
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/
|
||||
│ └── env.js # 環境変数の一元管理
|
||||
├── utils/
|
||||
│ └── pds.js # PDS判定・API設定ユーティリティ
|
||||
├── api/
|
||||
│ └── atproto.js # ATProto API クライアント
|
||||
├── hooks/
|
||||
│ ├── useAuth.js # OAuth認証フック
|
||||
│ ├── useAdminData.js # 管理者データ取得フック
|
||||
│ └── usePageContext.js # ページ判定フック
|
||||
├── services/
|
||||
│ └── oauth.js # OAuth認証サービス
|
||||
├── components/
|
||||
│ ├── AuthButton.jsx # ログイン/ログアウトボタン
|
||||
│ ├── RecordTabs.jsx # Lang/Commentタブ切り替え
|
||||
│ ├── RecordList.jsx # レコード表示リスト
|
||||
│ ├── UserLookup.jsx # ユーザー検索(未使用)
|
||||
│ └── OAuthCallback.jsx # OAuth コールバック処理
|
||||
└── App.jsx # メインアプリケーション
|
||||
```
|
||||
|
||||
## 環境設定
|
||||
|
||||
### .env ファイル
|
||||
|
||||
```bash
|
||||
VITE_ADMIN=ai.syui.ai # 管理者ハンドル
|
||||
VITE_PDS=syu.is # デフォルトPDS
|
||||
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"] # syu.is系ハンドルリスト
|
||||
VITE_COLLECTION=ai.syui.log # ベースコレクション
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json # OAuth クライアントID
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback # OAuth リダイレクトURI
|
||||
```
|
||||
|
||||
### 必要な依存関係
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.12",
|
||||
"@atproto/oauth-client-browser": "^0.3.19",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 主要機能
|
||||
|
||||
### 1. OAuth認証システム
|
||||
|
||||
**実装場所**: `src/services/oauth.js`
|
||||
|
||||
- `@atproto/oauth-client-browser`を使用した標準OAuth実装
|
||||
- bsky.social と syu.is 両方のPDSに対応
|
||||
- セッション自動復元機能
|
||||
|
||||
**重要**: ATProtoのセッション管理は複雑なため、公式ライブラリの使用が必須です。
|
||||
|
||||
### 2. PDS判定システム
|
||||
|
||||
**実装場所**: `src/utils/pds.js`
|
||||
|
||||
```javascript
|
||||
// ハンドル判定ロジック
|
||||
isSyuIsHandle(handle) → boolean
|
||||
// PDS設定取得
|
||||
getApiConfig(pds) → { pds, bsky, plc, web }
|
||||
```
|
||||
|
||||
環境変数`VITE_HANDLE_LIST`と`VITE_PDS`を基に自動判定します。
|
||||
|
||||
### 3. コレクション取得システム
|
||||
|
||||
**実装場所**: `src/api/atproto.js`
|
||||
|
||||
```javascript
|
||||
// 基本コレクション
|
||||
collections.getBase(pds, repo, collection)
|
||||
// lang コレクション(翻訳系)
|
||||
collections.getLang(pds, repo, collection) // → {collection}.chat.lang
|
||||
// comment コレクション(コメント系)
|
||||
collections.getComment(pds, repo, collection) // → {collection}.chat.comment
|
||||
```
|
||||
|
||||
### 4. ページコンテキスト判定
|
||||
|
||||
**実装場所**: `src/hooks/usePageContext.js`
|
||||
|
||||
```javascript
|
||||
// URL解析結果
|
||||
{
|
||||
isTopPage: boolean, // トップページかどうか
|
||||
rkey: string | null, // 個別ページのrkey(/posts/xxx → xxx)
|
||||
url: string // 現在のURL
|
||||
}
|
||||
```
|
||||
|
||||
## 表示ロジック
|
||||
|
||||
### フィルタリング
|
||||
|
||||
1. **トップページ**: 最新3件を表示
|
||||
2. **個別ページ**: `record.value.post.url`の rkey が現在ページと一致するもののみ表示
|
||||
|
||||
### タブ切り替え
|
||||
|
||||
- Lang Records: `{collection}.chat.lang`
|
||||
- Comment Records: `{collection}.chat.comment`
|
||||
|
||||
## 開発・デバッグ
|
||||
|
||||
### 起動コマンド
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 開発サーバー
|
||||
npm run build # プロダクションビルド
|
||||
```
|
||||
|
||||
### OAuth デバッグ
|
||||
|
||||
1. **ローカル開発**: 自動的にloopback clientが使用される
|
||||
2. **本番環境**: `client-metadata.json`が必要
|
||||
|
||||
```json
|
||||
// public/client-metadata.json
|
||||
{
|
||||
"client_id": "https://syui.ai/client-metadata.json",
|
||||
"client_name": "ATProto Comment System",
|
||||
"redirect_uris": ["https://syui.ai/oauth/callback"],
|
||||
"scope": "atproto",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"application_type": "web",
|
||||
"dpop_bound_access_tokens": true
|
||||
}
|
||||
```
|
||||
|
||||
### よくある問題
|
||||
|
||||
1. **セッションが保存されない**
|
||||
- `@atproto/oauth-client-browser`のバージョン確認
|
||||
- IndexedDBの確認(ブラウザの開発者ツール)
|
||||
|
||||
2. **PDS判定が正しく動作しない**
|
||||
- `VITE_HANDLE_LIST`の JSON 形式を確認
|
||||
- 環境変数の読み込み確認
|
||||
|
||||
3. **レコードが表示されない**
|
||||
- 管理者アカウントの DID 解決確認
|
||||
- コレクション名の確認(`{base}.chat.lang`, `{base}.chat.comment`)
|
||||
|
||||
## API エンドポイント
|
||||
|
||||
### 使用しているATProto API
|
||||
|
||||
1. **com.atproto.repo.describeRepo**
|
||||
- ハンドル → DID, PDS解決
|
||||
|
||||
2. **app.bsky.actor.getProfile**
|
||||
- プロフィール情報取得
|
||||
|
||||
3. **com.atproto.repo.listRecords**
|
||||
- コレクションレコード取得
|
||||
|
||||
## セキュリティ
|
||||
|
||||
- OAuth 2.1 + PKCE による認証
|
||||
- DPoP (Demonstration of Proof of Possession) 対応
|
||||
- セッション情報はブラウザのIndexedDBに暗号化保存
|
||||
|
||||
## 今後の拡張可能性
|
||||
|
||||
1. **コメント投稿機能**
|
||||
- 認証済みユーザーによるコメント作成
|
||||
- `com.atproto.repo.putRecord` API使用
|
||||
|
||||
2. **リアルタイム更新**
|
||||
- Jetstream WebSocket 接続
|
||||
- 新しいレコードの自動表示
|
||||
|
||||
3. **マルチPDS対応**
|
||||
- より多くのPDSへの対応
|
||||
- 動的PDS判定の改善
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### ログ確認
|
||||
ブラウザの開発者ツールでコンソールログを確認してください。主要なエラーは以下の通りです:
|
||||
|
||||
- `OAuth initialization failed`: OAuth設定の問題
|
||||
- `Failed to load admin data`: API アクセスエラー
|
||||
- `Auth check failed`: セッション復元エラー
|
||||
|
||||
### 環境変数確認
|
||||
```javascript
|
||||
// 開発者ツールのコンソールで確認
|
||||
console.log(import.meta.env)
|
||||
```
|
||||
|
||||
## 参考資料
|
||||
|
||||
- [ATProto OAuth Guide](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md)
|
||||
- [BrowserOAuthClient Documentation](https://github.com/bluesky-social/atproto/tree/main/packages/oauth-client-browser)
|
||||
- [ATProto API Reference](https://docs.bsky.app/docs/advanced-guides/atproto-api)
|
11
oauth_new/index.html
Normal file
11
oauth_new/index.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Comments Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="comment-atproto"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
22
oauth_new/package.json
Normal file
22
oauth_new/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oauth-simple",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@atproto/api": "^0.15.12",
|
||||
"@atproto/oauth-client-browser": "^0.3.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
76
oauth_new/src/App.jsx
Normal file
76
oauth_new/src/App.jsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import { useAuth } from './hooks/useAuth.js'
|
||||
import { useAdminData } from './hooks/useAdminData.js'
|
||||
import { useUserData } from './hooks/useUserData.js'
|
||||
import { usePageContext } from './hooks/usePageContext.js'
|
||||
import AuthButton from './components/AuthButton.jsx'
|
||||
import RecordTabs from './components/RecordTabs.jsx'
|
||||
import CommentForm from './components/CommentForm.jsx'
|
||||
import OAuthCallback from './components/OAuthCallback.jsx'
|
||||
|
||||
export default function App() {
|
||||
const { user, agent, loading: authLoading, login, logout } = useAuth()
|
||||
const { adminData, langRecords, commentRecords, loading: dataLoading, error, refresh: refreshAdminData } = useAdminData()
|
||||
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
|
||||
const pageContext = usePageContext()
|
||||
|
||||
// Handle OAuth callback
|
||||
if (window.location.search.includes('code=')) {
|
||||
return <OAuthCallback />
|
||||
}
|
||||
|
||||
const isLoading = authLoading || dataLoading || userLoading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>ATProto OAuth Demo</h1>
|
||||
<p>読み込み中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>ATProto OAuth Demo</h1>
|
||||
<p style={{ color: 'red' }}>エラー: {error}</p>
|
||||
<button onClick={() => window.location.reload()}>
|
||||
再読み込み
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<header style={{ marginBottom: '20px' }}>
|
||||
<h1>ATProto OAuth Demo</h1>
|
||||
<AuthButton
|
||||
user={user}
|
||||
onLogin={login}
|
||||
onLogout={logout}
|
||||
loading={authLoading}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<CommentForm
|
||||
user={user}
|
||||
agent={agent}
|
||||
onCommentPosted={() => {
|
||||
refreshAdminData?.()
|
||||
refreshUserData?.()
|
||||
}}
|
||||
/>
|
||||
|
||||
<RecordTabs
|
||||
langRecords={langRecords}
|
||||
commentRecords={commentRecords}
|
||||
userComments={userComments}
|
||||
chatRecords={chatRecords}
|
||||
apiConfig={adminData.apiConfig}
|
||||
pageContext={pageContext}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
80
oauth_new/src/api/atproto.js
Normal file
80
oauth_new/src/api/atproto.js
Normal file
@ -0,0 +1,80 @@
|
||||
// ATProto API client
|
||||
const ENDPOINTS = {
|
||||
describeRepo: 'com.atproto.repo.describeRepo',
|
||||
getProfile: 'app.bsky.actor.getProfile',
|
||||
listRecords: 'com.atproto.repo.listRecords',
|
||||
putRecord: 'com.atproto.repo.putRecord'
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const atproto = {
|
||||
async getDid(pds, handle) {
|
||||
const res = await request(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
|
||||
return res.did
|
||||
},
|
||||
|
||||
async getProfile(bsky, actor) {
|
||||
return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
|
||||
},
|
||||
|
||||
async getRecords(pds, repo, collection, limit = 10) {
|
||||
const res = await request(`${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`)
|
||||
return res.records || []
|
||||
},
|
||||
|
||||
async searchPlc(plc, did) {
|
||||
try {
|
||||
const data = await request(`${plc}/${did}`)
|
||||
return {
|
||||
success: true,
|
||||
endpoint: data?.service?.[0]?.serviceEndpoint || null,
|
||||
handle: data?.alsoKnownAs?.[0]?.replace('at://', '') || null
|
||||
}
|
||||
} catch {
|
||||
return { success: false, endpoint: null, handle: null }
|
||||
}
|
||||
},
|
||||
|
||||
async putRecord(pds, record, agent) {
|
||||
if (!agent) {
|
||||
throw new Error('Agent required for putRecord')
|
||||
}
|
||||
|
||||
// Use Agent's putRecord method instead of direct fetch
|
||||
return await agent.com.atproto.repo.putRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
// Collection specific methods
|
||||
export const collections = {
|
||||
async getBase(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, collection, limit)
|
||||
},
|
||||
|
||||
async getLang(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
||||
},
|
||||
|
||||
async getComment(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
||||
},
|
||||
|
||||
async getChat(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
|
||||
},
|
||||
|
||||
async getUserList(pds, repo, collection, limit = 100) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.user`, limit)
|
||||
},
|
||||
|
||||
async getUserComments(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, collection, limit)
|
||||
}
|
||||
}
|
102
oauth_new/src/components/AuthButton.jsx
Normal file
102
oauth_new/src/components/AuthButton.jsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
const [handleInput, setHandleInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!handleInput.trim() || isLoading) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await onLogin(handleInput.trim())
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('ログインに失敗しました: ' + error.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div>認証状態を確認中...</div>
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="auth-status">
|
||||
<div>ログイン中: <strong>{user.handle}</strong></div>
|
||||
<button onClick={onLogout} className="logout-btn">
|
||||
ログアウト
|
||||
</button>
|
||||
<style jsx>{`
|
||||
.auth-status {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.logout-btn {
|
||||
margin-top: 5px;
|
||||
padding: 5px 10px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-form">
|
||||
<h3>OAuth認証</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="Handle (e.g. your.handle.com)"
|
||||
disabled={isLoading}
|
||||
className="handle-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !handleInput.trim()}
|
||||
className="login-btn"
|
||||
>
|
||||
{isLoading ? 'ログイン中...' : 'ログイン'}
|
||||
</button>
|
||||
</form>
|
||||
<style jsx>{`
|
||||
.auth-form {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.handle-input {
|
||||
width: 200px;
|
||||
margin-right: 10px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.login-btn {
|
||||
padding: 5px 10px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
200
oauth_new/src/components/CommentForm.jsx
Normal file
200
oauth_new/src/components/CommentForm.jsx
Normal file
@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto } from '../api/atproto.js'
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
export default function CommentForm({ user, agent, onCommentPosted }) {
|
||||
const [text, setText] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!text.trim() || !url.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Create ai.syui.log record structure
|
||||
const record = {
|
||||
repo: user.did,
|
||||
collection: env.collection,
|
||||
rkey: `comment-${Date.now()}`,
|
||||
record: {
|
||||
$type: env.collection,
|
||||
url: url.trim(),
|
||||
comments: [
|
||||
{
|
||||
url: url.trim(),
|
||||
text: text.trim(),
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar
|
||||
},
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
],
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Post the record
|
||||
await atproto.putRecord(null, record, agent)
|
||||
|
||||
// Clear form
|
||||
setText('')
|
||||
setUrl('')
|
||||
|
||||
// Notify parent component
|
||||
if (onCommentPosted) {
|
||||
onCommentPosted()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="comment-form-placeholder">
|
||||
<p>ログインしてコメントを投稿</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comment-form">
|
||||
<h3>コメントを投稿</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment-url">ページURL:</label>
|
||||
<input
|
||||
id="comment-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://syui.ai/posts/example"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment-text">コメント:</label>
|
||||
<textarea
|
||||
id="comment-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="コメントを入力してください..."
|
||||
rows={4}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
エラー: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !text.trim() || !url.trim()}
|
||||
className="submit-btn"
|
||||
>
|
||||
{loading ? '投稿中...' : 'コメントを投稿'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style jsx>{`
|
||||
.comment-form {
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.comment-form-placeholder {
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.comment-form h3 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled {
|
||||
background: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.submit-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
50
oauth_new/src/components/OAuthCallback.jsx
Normal file
50
oauth_new/src/components/OAuthCallback.jsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export default function OAuthCallback({ onAuthSuccess }) {
|
||||
const [status, setStatus] = useState('OAuth認証処理中...')
|
||||
|
||||
useEffect(() => {
|
||||
handleCallback()
|
||||
}, [])
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// BrowserOAuthClientが自動的にコールバックを処理します
|
||||
// URLのパラメータを確認して成功を通知
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const error = urlParams.get('error')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`)
|
||||
}
|
||||
|
||||
if (code) {
|
||||
setStatus('認証成功!メインページに戻ります...')
|
||||
|
||||
// 少し待ってからメインページにリダイレクト
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 1500)
|
||||
} else {
|
||||
setStatus('認証情報が見つかりません')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error)
|
||||
setStatus('認証エラー: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h2>OAuth認証</h2>
|
||||
<p>{status}</p>
|
||||
{status.includes('エラー') && (
|
||||
<button onClick={() => window.location.href = '/'}>
|
||||
メインページに戻る
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
58
oauth_new/src/components/RecordList.jsx
Normal file
58
oauth_new/src/components/RecordList.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function RecordList({ title, records, apiConfig, showTitle = true }) {
|
||||
if (!records || records.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
{showTitle && <h3>{title} (0)</h3>}
|
||||
<p>レコードがありません</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{showTitle && <h3>{title} ({records.length})</h3>}
|
||||
{records.map((record, i) => (
|
||||
<div key={i} style={{ border: '1px solid #ddd', margin: '10px 0', padding: '10px' }}>
|
||||
{record.value.author?.avatar && (
|
||||
<img
|
||||
src={record.value.author.avatar}
|
||||
alt="avatar"
|
||||
style={{ width: '32px', height: '32px', borderRadius: '50%', marginRight: '10px' }}
|
||||
/>
|
||||
)}
|
||||
<div><strong>{record.value.author?.displayName || record.value.author?.handle}</strong></div>
|
||||
<div>
|
||||
Handle:
|
||||
<a
|
||||
href={`${apiConfig?.web}/profile/${record.value.author?.did}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: '5px' }}
|
||||
>
|
||||
{record.value.author?.handle}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ margin: '10px 0' }}>{record.value.text || record.value.content}</div>
|
||||
{record.value.post?.url && (
|
||||
<div>
|
||||
URL:
|
||||
<a
|
||||
href={record.value.post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: '5px' }}
|
||||
>
|
||||
{record.value.post.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
|
||||
{new Date(record.value.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
151
oauth_new/src/components/RecordTabs.jsx
Normal file
151
oauth_new/src/components/RecordTabs.jsx
Normal file
@ -0,0 +1,151 @@
|
||||
import React, { useState } from 'react'
|
||||
import RecordList from './RecordList.jsx'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
|
||||
const [activeTab, setActiveTab] = useState('lang')
|
||||
|
||||
// Filter records based on page context
|
||||
const filterRecords = (records) => {
|
||||
if (pageContext.isTopPage) {
|
||||
// Top page: show latest 3 records
|
||||
return records.slice(0, 3)
|
||||
} else {
|
||||
// Individual page: show records matching the URL
|
||||
return records.filter(record => {
|
||||
const recordUrl = record.value.post?.url
|
||||
if (!recordUrl) return false
|
||||
|
||||
try {
|
||||
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
||||
return recordRkey === pageContext.rkey
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLangRecords = filterRecords(langRecords)
|
||||
const filteredCommentRecords = filterRecords(commentRecords)
|
||||
const filteredUserComments = filterRecords(userComments || [])
|
||||
const filteredChatRecords = filterRecords(chatRecords || [])
|
||||
|
||||
return (
|
||||
<div className="record-tabs">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('lang')}
|
||||
>
|
||||
Lang Records ({filteredLangRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('comment')}
|
||||
>
|
||||
Comment Records ({filteredCommentRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
>
|
||||
Collection ({filteredChatRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
User Comments ({filteredUserComments.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'lang' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
|
||||
records={filteredLangRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'comment' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
|
||||
records={filteredCommentRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'collection' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
|
||||
records={filteredChatRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'users' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
|
||||
records={filteredUserComments}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="page-info">
|
||||
<small>
|
||||
{pageContext.isTopPage
|
||||
? "トップページ: 最新3件を表示"
|
||||
: `個別ページ: ${pageContext.rkey} に関連するレコードを表示`
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.record-tabs {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.tab-header {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #ddd;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: #f8f9fa;
|
||||
border-top: 2px solid transparent;
|
||||
border-left: 1px solid #ddd;
|
||||
border-right: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.tab-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: white;
|
||||
border-top-color: #007bff;
|
||||
border-bottom: 2px solid white;
|
||||
margin-bottom: -2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-btn:hover:not(.active) {
|
||||
background: #e9ecef;
|
||||
}
|
||||
.tab-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
.page-info {
|
||||
margin-top: 10px;
|
||||
padding: 5px 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
color: #666;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
115
oauth_new/src/components/UserLookup.jsx
Normal file
115
oauth_new/src/components/UserLookup.jsx
Normal file
@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto } from '../api/atproto.js'
|
||||
import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
|
||||
|
||||
export default function UserLookup() {
|
||||
const [handleInput, setHandleInput] = useState('')
|
||||
const [userInfo, setUserInfo] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!handleInput.trim() || loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const userPds = await getPdsFromHandle(handleInput)
|
||||
const apiConfig = getApiConfig(userPds)
|
||||
const did = await atproto.getDid(userPds.replace('https://', ''), handleInput)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
setUserInfo({
|
||||
handle: handleInput,
|
||||
pds: userPds,
|
||||
did,
|
||||
profile,
|
||||
config: apiConfig
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('User lookup failed:', error)
|
||||
setUserInfo({ error: error.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="user-lookup">
|
||||
<h3>ユーザー検索</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="Enter handle (e.g. syui.syui.ai)"
|
||||
disabled={loading}
|
||||
className="search-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !handleInput.trim()}
|
||||
className="search-btn"
|
||||
>
|
||||
{loading ? '検索中...' : '検索'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{userInfo && (
|
||||
<div className="user-result">
|
||||
<h4>ユーザー情報:</h4>
|
||||
{userInfo.error ? (
|
||||
<div className="error">エラー: {userInfo.error}</div>
|
||||
) : (
|
||||
<div className="user-details">
|
||||
<div>Handle: {userInfo.handle}</div>
|
||||
<div>PDS: {userInfo.pds}</div>
|
||||
<div>DID: {userInfo.did}</div>
|
||||
<div>Display Name: {userInfo.profile?.displayName}</div>
|
||||
<div>PDS API: {userInfo.config?.pds}</div>
|
||||
<div>Bsky API: {userInfo.config?.bsky}</div>
|
||||
<div>Web: {userInfo.config?.web}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.user-lookup {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.search-input {
|
||||
width: 200px;
|
||||
margin-right: 10px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.search-btn {
|
||||
padding: 5px 10px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.user-result {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
.user-details div {
|
||||
margin: 5px 0;
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
)
|
||||
}
|
17
oauth_new/src/config/env.js
Normal file
17
oauth_new/src/config/env.js
Normal file
@ -0,0 +1,17 @@
|
||||
// Environment configuration
|
||||
export const env = {
|
||||
admin: import.meta.env.VITE_ADMIN,
|
||||
pds: import.meta.env.VITE_PDS,
|
||||
collection: import.meta.env.VITE_COLLECTION,
|
||||
handleList: (() => {
|
||||
try {
|
||||
return JSON.parse(import.meta.env.VITE_HANDLE_LIST || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})(),
|
||||
oauth: {
|
||||
clientId: import.meta.env.VITE_OAUTH_CLIENT_ID,
|
||||
redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI
|
||||
}
|
||||
}
|
57
oauth_new/src/hooks/useAdminData.js
Normal file
57
oauth_new/src/hooks/useAdminData.js
Normal file
@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { atproto, collections } from '../api/atproto.js'
|
||||
import { getApiConfig } from '../utils/pds.js'
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
export function useAdminData() {
|
||||
const [adminData, setAdminData] = useState({
|
||||
did: '',
|
||||
profile: null,
|
||||
records: [],
|
||||
apiConfig: null
|
||||
})
|
||||
const [langRecords, setLangRecords] = useState([])
|
||||
const [commentRecords, setCommentRecords] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAdminData()
|
||||
}, [])
|
||||
|
||||
const loadAdminData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
||||
const did = await atproto.getDid(env.pds, env.admin)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
// Load all data in parallel
|
||||
const [records, lang, comment] = await Promise.all([
|
||||
collections.getBase(apiConfig.pds, did, env.collection),
|
||||
collections.getLang(apiConfig.pds, did, env.collection),
|
||||
collections.getComment(apiConfig.pds, did, env.collection)
|
||||
])
|
||||
|
||||
setAdminData({ did, profile, records, apiConfig })
|
||||
setLangRecords(lang)
|
||||
setCommentRecords(comment)
|
||||
} catch (err) {
|
||||
console.error('Failed to load admin data:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
loading,
|
||||
error,
|
||||
refresh: loadAdminData
|
||||
}
|
||||
}
|
47
oauth_new/src/hooks/useAuth.js
Normal file
47
oauth_new/src/hooks/useAuth.js
Normal file
@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { OAuthService } from '../services/oauth.js'
|
||||
|
||||
const oauthService = new OAuthService()
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState(null)
|
||||
const [agent, setAgent] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
initAuth()
|
||||
}, [])
|
||||
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const authResult = await oauthService.checkAuth()
|
||||
if (authResult) {
|
||||
setUser(authResult.user)
|
||||
setAgent(authResult.agent)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (handle) => {
|
||||
await oauthService.login(handle)
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
await oauthService.logout()
|
||||
setUser(null)
|
||||
setAgent(null)
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
agent,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!user
|
||||
}
|
||||
}
|
33
oauth_new/src/hooks/usePageContext.js
Normal file
33
oauth_new/src/hooks/usePageContext.js
Normal file
@ -0,0 +1,33 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function usePageContext() {
|
||||
const [pageContext, setPageContext] = useState({
|
||||
isTopPage: true,
|
||||
rkey: null,
|
||||
url: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const pathname = window.location.pathname
|
||||
const url = window.location.href
|
||||
|
||||
// Extract rkey from URL pattern: /posts/xxx or /posts/xxx.html
|
||||
const match = pathname.match(/\/posts\/([^/]+)\/?$/)
|
||||
if (match) {
|
||||
const rkey = match[1].replace(/\.html$/, '')
|
||||
setPageContext({
|
||||
isTopPage: false,
|
||||
rkey,
|
||||
url
|
||||
})
|
||||
} else {
|
||||
setPageContext({
|
||||
isTopPage: true,
|
||||
rkey: null,
|
||||
url
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return pageContext
|
||||
}
|
164
oauth_new/src/hooks/useUserData.js
Normal file
164
oauth_new/src/hooks/useUserData.js
Normal file
@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { atproto, collections } from '../api/atproto.js'
|
||||
import { getApiConfig, isSyuIsHandle } from '../utils/pds.js'
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
export function useUserData(adminData) {
|
||||
const [userComments, setUserComments] = useState([])
|
||||
const [chatRecords, setChatRecords] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminData?.did || !adminData?.apiConfig) return
|
||||
|
||||
const fetchUserData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 1. Get user list from admin account
|
||||
const userListRecords = await collections.getUserList(
|
||||
adminData.apiConfig.pds,
|
||||
adminData.did,
|
||||
env.collection
|
||||
)
|
||||
|
||||
// 2. Get chat records (ai.syui.log.chat doesn't exist, so skip for now)
|
||||
setChatRecords([])
|
||||
|
||||
// 3. Get base collection records which contain user comments
|
||||
const baseRecords = await collections.getBase(
|
||||
adminData.apiConfig.pds,
|
||||
adminData.did,
|
||||
env.collection
|
||||
)
|
||||
|
||||
// Extract comments from base records
|
||||
const allUserComments = []
|
||||
|
||||
for (const record of baseRecords) {
|
||||
if (record.value?.comments && Array.isArray(record.value.comments)) {
|
||||
// Each comment already has author info, so we can use it directly
|
||||
const commentsWithMeta = record.value.comments.map(comment => ({
|
||||
uri: record.uri,
|
||||
cid: record.cid,
|
||||
value: {
|
||||
...comment,
|
||||
post: {
|
||||
url: record.value.url
|
||||
}
|
||||
}
|
||||
}))
|
||||
allUserComments.push(...commentsWithMeta)
|
||||
}
|
||||
}
|
||||
|
||||
// Also try to get individual user records from the user list
|
||||
// Currently skipping user list processing since users contain placeholder DIDs
|
||||
if (userListRecords.length > 0 && userListRecords[0].value?.users) {
|
||||
console.log('User list found, but skipping placeholder users for now')
|
||||
|
||||
// Filter out placeholder users
|
||||
const realUsers = userListRecords[0].value.users.filter(user =>
|
||||
user.handle &&
|
||||
user.did &&
|
||||
!user.did.includes('placeholder') &&
|
||||
!user.did.includes('example')
|
||||
)
|
||||
|
||||
if (realUsers.length > 0) {
|
||||
console.log(`Processing ${realUsers.length} real users`)
|
||||
|
||||
for (const user of realUsers) {
|
||||
const userHandle = user.handle
|
||||
|
||||
try {
|
||||
// Get user's DID and PDS using PDS detection logic
|
||||
let userDid, userPds, userApiConfig
|
||||
|
||||
if (user.did && user.pds) {
|
||||
// Use DID and PDS from user record
|
||||
userDid = user.did
|
||||
userPds = user.pds.replace('https://', '')
|
||||
userApiConfig = getApiConfig(userPds)
|
||||
} else {
|
||||
// Auto-detect PDS based on handle and get real DID
|
||||
if (isSyuIsHandle(userHandle)) {
|
||||
userPds = env.pds
|
||||
userApiConfig = getApiConfig(userPds)
|
||||
userDid = await atproto.getDid(userPds, userHandle)
|
||||
} else {
|
||||
userPds = 'bsky.social'
|
||||
userApiConfig = getApiConfig(userPds)
|
||||
userDid = await atproto.getDid(userPds, userHandle)
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's own ai.syui.log records
|
||||
const userRecords = await collections.getUserComments(
|
||||
userApiConfig.pds,
|
||||
userDid,
|
||||
env.collection
|
||||
)
|
||||
|
||||
// Skip if no records found
|
||||
if (!userRecords || userRecords.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get user's profile for enrichment
|
||||
let profile = null
|
||||
try {
|
||||
profile = await atproto.getProfile(userApiConfig.bsky, userDid)
|
||||
} catch (profileError) {
|
||||
console.warn(`Failed to get profile for ${userHandle}:`, profileError)
|
||||
}
|
||||
|
||||
// Add profile info to each record
|
||||
const enrichedRecords = userRecords.map(record => ({
|
||||
...record,
|
||||
value: {
|
||||
...record.value,
|
||||
author: {
|
||||
did: userDid,
|
||||
handle: profile?.data?.handle || userHandle,
|
||||
displayName: profile?.data?.displayName || userHandle,
|
||||
avatar: profile?.data?.avatar || null
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
allUserComments.push(...enrichedRecords)
|
||||
} catch (userError) {
|
||||
console.warn(`Failed to fetch data for user ${userHandle}:`, userError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('No real users found in user list - all appear to be placeholders')
|
||||
}
|
||||
}
|
||||
|
||||
setUserComments(allUserComments)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchUserData()
|
||||
}, [adminData])
|
||||
|
||||
const refresh = () => {
|
||||
if (adminData?.did && adminData?.apiConfig) {
|
||||
// Re-trigger the effect by clearing and re-setting adminData
|
||||
const currentAdminData = adminData
|
||||
setUserComments([])
|
||||
setChatRecords([])
|
||||
// The useEffect will automatically run again
|
||||
}
|
||||
}
|
||||
|
||||
return { userComments, chatRecords, loading, error, refresh }
|
||||
}
|
5
oauth_new/src/main.jsx
Normal file
5
oauth_new/src/main.jsx
Normal file
@ -0,0 +1,5 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('comment-atproto')).render(<App />)
|
144
oauth_new/src/services/oauth.js
Normal file
144
oauth_new/src/services/oauth.js
Normal file
@ -0,0 +1,144 @@
|
||||
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
|
||||
import { Agent } from '@atproto/api'
|
||||
import { env } from '../config/env.js'
|
||||
import { isSyuIsHandle } from '../utils/pds.js'
|
||||
|
||||
export class OAuthService {
|
||||
constructor() {
|
||||
this.clientId = env.oauth.clientId || this.getClientId()
|
||||
this.clients = { bsky: null, syu: null }
|
||||
this.agent = null
|
||||
this.sessionInfo = null
|
||||
this.initPromise = null
|
||||
}
|
||||
|
||||
getClientId() {
|
||||
const origin = window.location.origin
|
||||
return origin.includes('localhost') || origin.includes('127.0.0.1')
|
||||
? undefined // Loopback client
|
||||
: `${origin}/client-metadata.json`
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initPromise) return this.initPromise
|
||||
|
||||
this.initPromise = this._initialize()
|
||||
return this.initPromise
|
||||
}
|
||||
|
||||
async _initialize() {
|
||||
try {
|
||||
// Initialize OAuth clients
|
||||
this.clients.bsky = await BrowserOAuthClient.load({
|
||||
clientId: this.clientId,
|
||||
handleResolver: 'https://bsky.social',
|
||||
plcDirectoryUrl: 'https://plc.directory',
|
||||
})
|
||||
|
||||
this.clients.syu = await BrowserOAuthClient.load({
|
||||
clientId: this.clientId,
|
||||
handleResolver: 'https://syu.is',
|
||||
plcDirectoryUrl: 'https://plc.syu.is',
|
||||
})
|
||||
|
||||
// Try to restore session
|
||||
return await this.restoreSession()
|
||||
} catch (error) {
|
||||
console.error('OAuth initialization failed:', error)
|
||||
this.initPromise = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async restoreSession() {
|
||||
// Try both clients
|
||||
for (const client of [this.clients.bsky, this.clients.syu]) {
|
||||
const result = await client.init()
|
||||
if (result?.session) {
|
||||
this.agent = new Agent(result.session)
|
||||
return this.processSession(result.session)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async processSession(session) {
|
||||
const did = session.sub || session.did
|
||||
let handle = session.handle || 'unknown'
|
||||
|
||||
this.sessionInfo = { did, handle }
|
||||
|
||||
// Resolve handle if missing
|
||||
if (handle === 'unknown' && this.agent) {
|
||||
try {
|
||||
const profile = await this.agent.getProfile({ actor: did })
|
||||
handle = profile.data.handle
|
||||
this.sessionInfo.handle = handle
|
||||
} catch (error) {
|
||||
console.log('Failed to resolve handle:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return { did, handle }
|
||||
}
|
||||
|
||||
async login(handle) {
|
||||
await this.initialize()
|
||||
|
||||
const client = isSyuIsHandle(handle) ? this.clients.syu : this.clients.bsky
|
||||
const authUrl = await client.authorize(handle, { scope: 'atproto' })
|
||||
|
||||
window.location.href = authUrl.toString()
|
||||
}
|
||||
|
||||
async checkAuth() {
|
||||
try {
|
||||
await this.initialize()
|
||||
if (this.sessionInfo) {
|
||||
return {
|
||||
user: this.sessionInfo,
|
||||
agent: this.agent
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
// Sign out from session
|
||||
if (this.clients.bsky) {
|
||||
const result = await this.clients.bsky.init()
|
||||
if (result?.session?.signOut) {
|
||||
await result.session.signOut()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear state
|
||||
this.agent = null
|
||||
this.sessionInfo = null
|
||||
this.clients = { bsky: null, syu: null }
|
||||
this.initPromise = null
|
||||
|
||||
// Clear storage
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
|
||||
// Reload page
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
getAgent() {
|
||||
return this.agent
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.sessionInfo
|
||||
}
|
||||
}
|
36
oauth_new/src/utils/pds.js
Normal file
36
oauth_new/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
|
||||
}
|
15
oauth_new/vite.config.js
Normal file
15
oauth_new/vite.config.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'assets/comment-atproto-[hash].js',
|
||||
chunkFileNames: 'assets/comment-atproto-[hash].js',
|
||||
assetFileNames: 'assets/comment-atproto-[hash].[ext]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
@ -3,10 +3,10 @@
|
||||
set -e
|
||||
|
||||
cb=ai.syui.log
|
||||
cl=( $cb.user )
|
||||
cl=( $cb.chat.lang $cb.chat.comment)
|
||||
f=~/.config/syui/ai/log/config.json
|
||||
|
||||
default_collection="ai.syui.log.chat.comment"
|
||||
default_collection="ai.syui.log.chat"
|
||||
default_pds="syu.is"
|
||||
default_did=`cat $f|jq -r .admin.did`
|
||||
default_token=`cat $f|jq -r .admin.access_jwt`
|
||||
|
@ -1426,21 +1426,8 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
|
||||
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
|
||||
.build()?;
|
||||
|
||||
// Try localhost first (for same-server deployment)
|
||||
let localhost_url = "http://localhost:11434/api/generate";
|
||||
match client.post(localhost_url).json(&request).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let ollama_response: OllamaResponse = response.json().await?;
|
||||
println!("{}", "✅ Used localhost Ollama".green());
|
||||
return Ok(ollama_response.response);
|
||||
}
|
||||
_ => {
|
||||
println!("{}", "⚠️ Localhost Ollama not available, trying remote...".yellow());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to remote host
|
||||
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
|
||||
// Use configured Ollama host
|
||||
let ollama_url = format!("{}/api/generate", ai_config.ollama_host);
|
||||
|
||||
// Check if this is a local/private network connection (no CORS needed)
|
||||
// RFC 1918 private networks + localhost
|
||||
@ -1461,13 +1448,13 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
|
||||
} else { false }
|
||||
});
|
||||
|
||||
let mut request_builder = client.post(&remote_url).json(&request);
|
||||
let mut request_builder = client.post(&ollama_url).json(&request);
|
||||
|
||||
if !is_local {
|
||||
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
|
||||
println!("{}", format!("🔗 Making request to: {} with Origin: {}", ollama_url, ai_config.blog_host).blue());
|
||||
request_builder = request_builder.header("Origin", &ai_config.blog_host);
|
||||
} else {
|
||||
println!("{}", format!("🔗 Making request to local network: {}", remote_url).blue());
|
||||
println!("{}", format!("🔗 Making request to local network: {}", ollama_url).blue());
|
||||
}
|
||||
|
||||
let response = request_builder.send().await?;
|
||||
@ -1477,7 +1464,7 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
|
||||
}
|
||||
|
||||
let ollama_response: OllamaResponse = response.json().await?;
|
||||
println!("{}", "✅ Used remote Ollama".green());
|
||||
println!("{}", "✅ Ollama request successful".green());
|
||||
Ok(ollama_response.response)
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user