8 Commits

Author SHA1 Message Date
4edde5293a Add oauth_new: Complete OAuth authentication and comment system
- Created new oauth_new directory with clean OAuth implementation
- Added 4-tab interface: Lang, Comment, Collection, User Comments
- Implemented OAuth authentication with @atproto/oauth-client-browser
- Added comment posting functionality with putRecord
- Added proper PDS detection and error handling
- Skipped placeholder users to prevent errors
- Built comprehensive documentation (README.md, DEVELOPMENT.md)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 22:34:03 +09:00
f0fdf678c8 fix oauth plc 2025-06-17 17:43:03 +09:00
820e47f634 update binary 2025-06-17 11:01:42 +09:00
4dac4a83e0 fix atproto web link 2025-06-17 11:00:09 +09:00
fccf75949c v0.2.1: Fix async trait implementation warnings
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 10:42:15 +09:00
6600a9e0cf test pds oauth did 2025-06-17 10:41:22 +09:00
0d79af5aa5 v0.2.0: Unified AI content display and OAuth PDS fixes
Major Changes:
- Unified AI content rendering across all collection types (chat, lang, comment)
- Fixed PDS endpoint detection and usage based on handle configuration
- Removed hardcoded 'yui.syui.ai' references and used environment variables
- Fixed OAuth app 400 errors by adding null checks for API calls
- Improved AI DID resolution to use correct ai.syui.ai account
- Fixed avatar and profile link generation for correct PDS routing
- Enhanced network configuration mapping for different PDS types

OAuth App Improvements:
- Consolidated renderAIContent() function for all AI collections
- Fixed generateProfileUrl() to use PDS-specific web URLs
- Removed duplicate AI content rendering code
- Added proper error handling for API calls

Technical Fixes:
- Updated stream.rs to use correct AI DID defaults
- Improved CORS handling for Ollama localhost connections
- Enhanced PDS detection logic for handle-based routing
- Cleaned up production code (removed console.log statements)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 01:51:11 +09:00
db04af76ab test cleanup 2025-06-17 01:48:30 +09:00
48 changed files with 2848 additions and 455 deletions

View File

@@ -51,7 +51,8 @@
"Bash(ailog:*)",
"WebFetch(domain:plc.directory)",
"WebFetch(domain:atproto.com)",
"WebFetch(domain:syu.is)"
"WebFetch(domain:syu.is)",
"Bash(sed:*)"
],
"deny": []
}

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ my-blog/templates/oauth-assets.html
cloudflared-config.yml
.config
oauth-server-example
atproto

View File

@@ -1,6 +1,6 @@
[package]
name = "ailog"
version = "0.1.9"
version = "0.2.1"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
@@ -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"

Binary file not shown.

208
claude.md
View File

@@ -14,6 +14,214 @@ VITE_OAUTH_COLLECTION_USER=ai.syui.log.user
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
```
## oauth appの設計
> ./oauth/.env.production
```sh
VITE_ATPROTO_PDS=syu.is
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_AI_HANDLE=ai.syui.ai
VITE_OAUTH_COLLECTION=ai.syui.log
```
これらは非常にシンプルな流れになっており、すべての項目は、共通します。短縮できる場合があります。handleは変わる可能性があるので、できる限りdidを使いましょう。
1. handleからpds, didを取得できる ... com.atproto.repo.describeRepo
2. pdsが分かれば、pdsApi, bskyApi, plcApiを割り当てられる
3. bskyApiが分かれば、getProfileでavatar-uriを取得できる ... app.bsky.actor.getProfile
4. pdsAPiからアカウントにあるcollectionのrecordの情報を取得できる ... com.atproto.repo.listRecords
### コメントを表示する
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.user`というuserlistを取得する。
```sh
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.user"
---
syui.ai
```
5. ユーザーがわかったら、そのユーザーのpdsを判定する。
```sh
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".didDoc.service.[].serviceEndpoint"
---
https://shiitake.us-east.host.bsky.network
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".did"
---
did:plc:uqzpqmrjnptsxezjx4xuh2mn
```
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
7. ユーザーの情報を取得、表示する
```sh
bsky_api=https://public.api.bsky.app
user_did=did:plc:uqzpqmrjnptsxezjx4xuh2mn
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
---
https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg
```
### AIの情報を表示する
AIが持つ`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を表示します。
なお、これは通常、`VITE_ADMIN_HANDLE`にputRecordされます。そこから情報を読み込みます。`VITE_AI_HANDLE`はそのrecordの`author`のところに入ります。
```json
"author": {
"did": "did:plc:4hqjfn7m6n5hno3doamuhgef",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg",
"handle": "yui.syui.ai",
"displayName": "ai"
}
```
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を取得する。
```sh
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.chat.comment"
```
5. AIのprofileを取得する。
```sh
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".didDoc.service.[].serviceEndpoint"
---
https://syu.is
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".did"
did:plc:6qyecktefllvenje24fcxnie
```
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
7. AIの情報を取得、表示する
```sh
bsky_api=https://bsky.syu.is
user_did=did:plc:6qyecktefllvenje24fcxnie
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
---
https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg
```
## 中核思想
- **存在子理論**: この世界で最も小さいもの(存在子/aiの探求
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保

View File

@@ -16,8 +16,10 @@ auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
host = "https://ollama.syui.ai"
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
@@ -28,4 +30,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"]

View File

@@ -9,12 +9,12 @@ 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
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_HOST=http://localhost:11434
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"

View File

@@ -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
}
}

View File

@@ -9,13 +9,13 @@ 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
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とか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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() {
@@ -118,6 +119,9 @@ function App() {
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
loadAiChatHistory();
// Load AI generated content (lang:en and AI comments)
loadAIGeneratedContent();
};
// Wait for DID resolution before loading data
@@ -154,6 +158,7 @@ function App() {
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
const apiEndpoint = config.bskyApi;
// Get profile from appropriate bsky API
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (profileResponse.ok) {
@@ -219,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);
@@ -290,6 +289,7 @@ function App() {
if (adminDid && aiDid) {
loadAllComments();
loadAiChatHistory();
loadAIGeneratedContent();
}
}, [adminDid, aiDid]);
@@ -331,19 +331,26 @@ function App() {
try {
// Load all chat records from users in admin's user list
const currentAdminDid = adminDid || appConfig.adminDid;
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
const collections = getCollectionNames(appConfig.collections.base);
// First, get user list from admin using their proper PDS
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
return;
}
// 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 {
adminPdsEndpoint = atprotoApi;
// Fallback to configured PDS
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
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`);
if (!userListResponse.ok) {
@@ -378,6 +385,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;
@@ -391,6 +402,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;
@@ -436,11 +450,27 @@ function App() {
const loadAIGeneratedContent = async () => {
try {
const currentAdminDid = adminDid || appConfig.adminDid;
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
return;
}
// 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
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
if (langResponse.ok) {
const langData = await langResponse.json();
const langRecords = langData.records || [];
@@ -457,8 +487,14 @@ function App() {
setLangEnRecords(filteredLangRecords);
}
// Load AI comment records
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
// Load AI comment records from admin account (not AI account)
if (!currentAdminDid) {
console.warn('No Admin DID available, skipping AI comment loading');
setAiCommentRecords([]);
return;
}
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
if (commentResponse.ok) {
const commentData = await commentResponse.json();
const commentRecords = commentData.records || [];
@@ -508,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: {
@@ -517,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
}
}
};
@@ -768,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: {
@@ -776,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
}
}
};
@@ -1092,14 +1144,34 @@ function App() {
// ユーザーハンドルからプロフィールURLを生成
const generateProfileUrl = (author: any): string => {
// Use stored PDS info if available (from comment enhancement)
// Check if this is admin/AI handle that should use configured PDS
if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) {
const config = getNetworkConfig(appConfig.atprotoPds);
return `${config.webUrl}/profile/${author.did}`;
}
// For ai.syu.is handle, also use configured PDS
if (author.handle === 'ai.syu.is') {
const config = getNetworkConfig(appConfig.atprotoPds);
return `${config.webUrl}/profile/${author.did}`;
}
// 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 to handle-based detection
// Fallback: Get PDS from handle for other users
const pds = detectPdsFromHandle(author.handle);
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
const config = getNetworkConfig(pds);
// Use DID for profile URL
return `${config.webUrl}/profile/${author.did}`;
};
@@ -1135,7 +1207,9 @@ function App() {
// Extract content based on format
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
const authorInfo = isNewFormat ? value.author : null;
// 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 || '';
@@ -1147,29 +1221,22 @@ function App() {
src={authorInfo?.avatar || generatePlaceholderAvatar('AI')}
alt="AI Avatar"
className="comment-avatar"
ref={(img) => {
// For old format, try to fetch from ai_did
if (img && !isNewFormat && value.ai_did) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(value.ai_did)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
{authorInfo?.displayName || 'AI'}
</span>
<span className="comment-handle">
@{authorInfo?.handle || aiProfile?.handle || 'yui.syui.ai'}
{authorInfo?.displayName || 'ai'}
</span>
<a
href={generateProfileUrl({
handle: authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle,
did: authorInfo?.did || aiProfile?.did || appConfig.aiDid
})}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle}
</a>
</div>
<span className="comment-date">
{new Date(createdAt).toLocaleString()}
@@ -1296,7 +1363,7 @@ function App() {
name="userList"
value={userListInput}
onChange={(e) => setUserListInput(e.target.value)}
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, yui.syui.ai, user.bsky.social"
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, ai.syui.ai, user.bsky.social"
rows={3}
disabled={isPostingUserList}
/>
@@ -1493,93 +1560,9 @@ function App() {
{aiChatHistory.length === 0 ? (
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
) : (
aiChatHistory.map((record, index) => {
// For AI responses, use AI DID; for user questions, use the actual author
const isAiResponse = record.value.type === 'answer';
const displayDid = isAiResponse ? (aiDid || appConfig.aiDid) : record.value.author?.did;
const displayHandle = isAiResponse ? (aiProfile?.handle || 'yui.syui.ai') : record.value.author?.handle;
const displayName = isAiResponse ? 'AI' : (record.value.author?.displayName || record.value.author?.handle);
return (
<div key={index} className="comment-item">
<div className="comment-header">
<img
src={generatePlaceholderAvatar(displayHandle || 'unknown')}
alt={isAiResponse ? "AI Avatar" : "User Avatar"}
className="comment-avatar"
ref={(img) => {
// Fetch fresh avatar from API when component mounts
if (img && displayDid) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(displayDid)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
{displayName || 'unknown'}
</span>
<a
href={generateProfileUrl({ handle: displayHandle, did: displayDid })}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{displayHandle || 'unknown'}
</a>
</div>
<span className="comment-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
<div className="comment-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
</button>
<button className="chat-type-button">
{record.value.type === 'question' ? 'Question' : 'Answer'}
</button>
</div>
</div>
<div className="comment-meta">
{record.value.post?.url && (
<small><a href={record.value.post.url}>{record.value.post.url}</a></small>
)}
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="comment-content">
{record.value.text?.split('\n').map((line: string, index: number) => (
<React.Fragment key={index}>
{line}
{index < record.value.text.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
);
})
aiChatHistory.map((record, index) =>
renderAIContent(record, index, 'comment-item')
)
)}
</div>
)}
@@ -1588,7 +1571,7 @@ function App() {
{activeTab === 'lang-en' && (
<div className="comments-list">
{langEnRecords.length === 0 ? (
<p className="no-content">No English translations yet</p>
<p className="no-content">No EN translations yet</p>
) : (
langEnRecords.map((record, index) =>
renderAIContent(record, index, 'lang-item')
@@ -1603,78 +1586,9 @@ function App() {
{aiCommentRecords.length === 0 ? (
<p className="no-content">No AI comments yet</p>
) : (
aiCommentRecords.map((record, index) => (
<div key={index} className="comment-item">
<div className="comment-header">
<img
src={generatePlaceholderAvatar('ai')}
alt="AI Avatar"
className="comment-avatar"
ref={(img) => {
// Fetch AI avatar
const currentAiDid = aiDid || appConfig.aiDid;
if (img && currentAiDid) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(currentAiDid)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
AI
</span>
<span className="comment-handle">
@{aiProfile?.handle || 'yui.syui.ai'}
</span>
</div>
<span className="comment-date">
{new Date(record.value.createdAt || record.value.generated_at).toLocaleString()}
</span>
<div className="comment-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
</button>
</div>
</div>
<div className="comment-meta">
{(record.value.post?.url || record.value.post_url) && (
<small><a href={record.value.post?.url || record.value.post_url}>{record.value.post?.url || record.value.post_url}</a></small>
)}
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="comment-content">
{(record.value.text || record.value.comment)?.split('\n').map((line: string, index: number) => (
<React.Fragment key={index}>
{line}
{index < (record.value.text || record.value.comment)?.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
))
aiCommentRecords.map((record, index) =>
renderAIContent(record, index, 'comment-item')
)
)}
</div>
)}

View File

@@ -191,6 +191,7 @@ Answer:`;
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.model,

View File

@@ -89,7 +89,7 @@ export function getAppConfig(): AppConfig {
// DIDsはハンドルから実行時に解決されるフォールバック用のみ保持
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef';
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
@@ -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';

View File

@@ -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,39 +182,15 @@ class AtprotoOAuthService {
return `${origin}/client-metadata.json`;
}
private detectPDSFromHandle(handle: string): string {
// Supported PDS hosts and their corresponding handles
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}`)) {
return pdsUrl;
}
}
// Default to 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
@@ -247,49 +201,27 @@ class AtprotoOAuthService {
}
}
// Detect PDS based on handle
const pdsUrl = this.detectPDSFromHandle(handle);
// Re-initialize OAuth client with correct PDS if needed
if (pdsUrl !== 'https://bsky.social') {
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
});
}
// Start OAuth authorization flow
// Determine which OAuth client to use
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
let allowedHandles: string[] = [];
try {
const authUrl = await this.oauthClient.authorize(handle, {
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) {
throw authorizeError;
allowedHandles = JSON.parse(allowedHandlesStr);
} catch {
allowedHandles = [];
}
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
// Start OAuth authorization flow
const authUrl = await oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
// Redirect to authorization server
window.location.href = authUrl.toString();
} catch (error) {
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
}
}
@@ -345,21 +277,15 @@ 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
@@ -424,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
@@ -457,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);
localStorage.removeItem(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Clear internal session info
(this as any)._sessionInfo = null;

View File

@@ -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');

View File

@@ -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<string, string>) => {
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');
}

View File

@@ -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) {

View File

@@ -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);
}

6
oauth_new/.env Normal file
View 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
View File

@@ -0,0 +1,334 @@
# 開発ガイド
## 設計思想
このプロジェクトは以下の原則に基づいて設計されています:
### 1. 環境変数による設定の外部化
- ハードコードを避け、設定は全て環境変数で管理
- `src/config/env.js` で一元管理
### 2. PDSPersonal 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へのセッション保存
- トークンの自動更新
- DPoPDemonstration 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要件
- [ ] CSPContent 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
View File

@@ -0,0 +1,222 @@
# ATProto OAuth Comment System
ATProtocolBlueskyの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
View 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
View 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
View 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>
)
}

View 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)
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}

View 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
View 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 />)

View 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
}
}

View 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
View 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]'
}
}
}
})

View File

@@ -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`

View File

@@ -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"

View File

@@ -1426,35 +1426,45 @@ 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());
}
// 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
let is_local = ai_config.ollama_host.contains("localhost") ||
ai_config.ollama_host.contains("127.0.0.1") ||
ai_config.ollama_host.contains("::1") ||
ai_config.ollama_host.contains("192.168.") || // 192.168.0.0/16
ai_config.ollama_host.contains("10.") || // 10.0.0.0/8
(ai_config.ollama_host.contains("172.") && { // 172.16.0.0/12
// Extract 172.x and check if x is 16-31
if let Some(start) = ai_config.ollama_host.find("172.") {
let after_172 = &ai_config.ollama_host[start + 4..];
if let Some(dot_pos) = after_172.find('.') {
if let Ok(second_octet) = after_172[..dot_pos].parse::<u8>() {
second_octet >= 16 && second_octet <= 31
} else { false }
} else { false }
} else { false }
});
let mut request_builder = client.post(&ollama_url).json(&request);
if !is_local {
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: {}", ollama_url).blue());
}
// Fallback to remote host
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
let response = client
.post(&remote_url)
.header("Origin", &ai_config.blog_host)
.json(&request)
.send()
.await?;
let response = request_builder.send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
}
let ollama_response: OllamaResponse = response.json().await?;
println!("{}", "Used remote Ollama".green());
println!("{}", "Ollama request successful".green());
Ok(ollama_response.response)
}

14
src/lib.rs Normal file
View File

@@ -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;

View File

@@ -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,

View File

@@ -42,9 +42,9 @@ pub enum MarkdownSection {
pub trait Translator {
#[allow(dead_code)]
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String>;
async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String>;
async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>>;
fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send;
}
#[allow(dead_code)]
@@ -67,6 +67,7 @@ pub struct TranslationMetrics {
pub sections_preserved: usize,
}
#[derive(Clone)]
pub struct LanguageMapping {
pub mappings: HashMap<String, LanguageInfo>,
}

View File

@@ -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<String> {
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<Output = Result<String>> + 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<String> {
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<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>> {
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<Output = Result<String>> + Send {
async move {
println!("🔄 Parsing markdown content...");
let sections = self.parser.parse_markdown(content)?;
let translated_section = match &section {
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(&section, 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<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + 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 &section {
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(&section, &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)
}
}