This commit is contained in:
2025-06-06 02:14:35 +09:00
parent 02dd69840d
commit a9dca2fe38
33 changed files with 2141 additions and 9 deletions

108
src/atproto/client.rs Normal file
View File

@ -0,0 +1,108 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
#[derive(Debug, Clone)]
pub struct AtprotoClient {
client: reqwest::Client,
handle_resolver: String,
access_token: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateRecordRequest {
pub repo: String,
pub collection: String,
pub record: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateRecordResponse {
pub uri: String,
pub cid: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CommentRecord {
#[serde(rename = "$type")]
pub record_type: String,
pub text: String,
pub createdAt: String,
pub postUri: String,
pub author: AuthorInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorInfo {
pub did: String,
pub handle: String,
}
impl AtprotoClient {
pub fn new(handle_resolver: String) -> Self {
Self {
client: reqwest::Client::new(),
handle_resolver,
access_token: None,
}
}
pub fn set_access_token(&mut self, token: String) {
self.access_token = Some(token);
}
pub async fn create_comment(&self, did: &str, post_uri: &str, text: &str) -> Result<CreateRecordResponse> {
if self.access_token.is_none() {
anyhow::bail!("Not authenticated");
}
let record = CommentRecord {
record_type: "app.bsky.feed.post".to_string(),
text: text.to_string(),
createdAt: chrono::Utc::now().to_rfc3339(),
postUri: post_uri.to_string(),
author: AuthorInfo {
did: did.to_string(),
handle: "".to_string(), // Will be resolved by the server
},
};
let request = CreateRecordRequest {
repo: did.to_string(),
collection: "app.bsky.feed.post".to_string(),
record: serde_json::to_value(record)?,
};
let response = self.client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", self.handle_resolver))
.header(AUTHORIZATION, format!("Bearer {}", self.access_token.as_ref().unwrap()))
.header(CONTENT_TYPE, "application/json")
.json(&request)
.send()
.await?;
if response.status().is_success() {
let result: CreateRecordResponse = response.json().await?;
Ok(result)
} else {
let error_text = response.text().await?;
anyhow::bail!("Failed to create comment: {}", error_text)
}
}
pub async fn get_profile(&self, did: &str) -> Result<serde_json::Value> {
let response = self.client
.get(format!("{}/xrpc/app.bsky.actor.getProfile", self.handle_resolver))
.query(&[("actor", did)])
.header(AUTHORIZATION, format!("Bearer {}", self.access_token.as_ref().unwrap_or(&String::new())))
.send()
.await?;
if response.status().is_success() {
let profile = response.json().await?;
Ok(profile)
} else {
anyhow::bail!("Failed to get profile")
}
}
}

120
src/atproto/comment_sync.rs Normal file
View File

@ -0,0 +1,120 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::fs;
use crate::atproto::client::AtprotoClient;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: String,
pub author: String,
pub author_did: String,
pub content: String,
pub timestamp: String,
pub post_slug: String,
pub atproto_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentStorage {
pub comments: Vec<Comment>,
}
pub struct CommentSync {
client: AtprotoClient,
storage_path: PathBuf,
}
impl CommentSync {
pub fn new(client: AtprotoClient, base_path: PathBuf) -> Self {
let storage_path = base_path.join("data/comments.json");
Self {
client,
storage_path,
}
}
pub async fn load_comments(&self) -> Result<CommentStorage> {
if self.storage_path.exists() {
let content = fs::read_to_string(&self.storage_path)?;
let storage: CommentStorage = serde_json::from_str(&content)?;
Ok(storage)
} else {
Ok(CommentStorage { comments: vec![] })
}
}
pub async fn save_comments(&self, storage: &CommentStorage) -> Result<()> {
if let Some(parent) = self.storage_path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(storage)?;
fs::write(&self.storage_path, content)?;
Ok(())
}
pub async fn add_comment(&mut self, post_slug: &str, author_did: &str, content: &str) -> Result<Comment> {
// Get author profile
let profile = self.client.get_profile(author_did).await?;
let author_handle = profile["handle"].as_str().unwrap_or("unknown").to_string();
// Create comment in atproto
let post_uri = format!("ailog://post/{}", post_slug);
let result = self.client.create_comment(author_did, &post_uri, content).await?;
// Create local comment record
let comment = Comment {
id: uuid::Uuid::new_v4().to_string(),
author: author_handle,
author_did: author_did.to_string(),
content: content.to_string(),
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
post_slug: post_slug.to_string(),
atproto_uri: Some(result.uri),
};
// Save to local storage
let mut storage = self.load_comments().await?;
storage.comments.push(comment.clone());
self.save_comments(&storage).await?;
Ok(comment)
}
pub async fn get_comments_for_post(&self, post_slug: &str) -> Result<Vec<Comment>> {
let storage = self.load_comments().await?;
Ok(storage.comments
.into_iter()
.filter(|c| c.post_slug == post_slug)
.collect())
}
}
// Helper to generate comment HTML
pub fn render_comments_html(comments: &[Comment]) -> String {
let mut html = String::from("<div class=\"comments\">\n");
html.push_str(" <h3>コメント</h3>\n");
if comments.is_empty() {
html.push_str(" <p>まだコメントはありません。</p>\n");
} else {
for comment in comments {
html.push_str(&format!(
r#" <div class="comment">
<div class="comment-header">
<span class="author">@{}</span>
<span class="timestamp">{}</span>
</div>
<div class="comment-content">{}</div>
</div>
"#,
comment.author,
comment.timestamp,
comment.content
));
}
}
html.push_str("</div>");
html
}

7
src/atproto/mod.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod oauth;
pub mod client;
pub mod comment_sync;
pub use oauth::OAuthHandler;
pub use client::AtprotoClient;
pub use comment_sync::CommentSync;

162
src/atproto/oauth.rs Normal file
View File

@ -0,0 +1,162 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::config::AtprotoConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMetadata {
pub client_id: String,
pub client_name: String,
pub client_uri: String,
pub logo_uri: String,
pub tos_uri: String,
pub policy_uri: String,
pub redirect_uris: Vec<String>,
pub scope: String,
pub grant_types: Vec<String>,
pub response_types: Vec<String>,
pub token_endpoint_auth_method: String,
pub application_type: String,
pub dpop_bound_access_tokens: bool,
}
#[derive(Debug, Clone)]
pub struct OAuthHandler {
config: AtprotoConfig,
client: reqwest::Client,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorizationRequest {
pub response_type: String,
pub client_id: String,
pub redirect_uri: String,
pub state: String,
pub scope: String,
pub code_challenge: String,
pub code_challenge_method: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
pub refresh_token: Option<String>,
pub scope: String,
}
impl OAuthHandler {
pub fn new(config: AtprotoConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
pub fn generate_client_metadata(&self) -> ClientMetadata {
ClientMetadata {
client_id: self.config.client_id.clone(),
client_name: "ailog - AI-powered blog".to_string(),
client_uri: "https://example.com".to_string(),
logo_uri: "https://example.com/logo.png".to_string(),
tos_uri: "https://example.com/tos".to_string(),
policy_uri: "https://example.com/policy".to_string(),
redirect_uris: vec![self.config.redirect_uri.clone()],
scope: "atproto".to_string(),
grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()],
response_types: vec!["code".to_string()],
token_endpoint_auth_method: "none".to_string(),
application_type: "web".to_string(),
dpop_bound_access_tokens: true,
}
}
pub fn generate_authorization_url(&self, state: &str, code_challenge: &str) -> String {
let params = vec![
("response_type", "code"),
("client_id", &self.config.client_id),
("redirect_uri", &self.config.redirect_uri),
("state", state),
("scope", "atproto"),
("code_challenge", code_challenge),
("code_challenge_method", "S256"),
];
let query = params.into_iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{}/oauth/authorize?{}", self.config.handle_resolver, query)
}
pub async fn exchange_code(&self, code: &str, code_verifier: &str) -> Result<TokenResponse> {
let params = HashMap::from([
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", &self.config.redirect_uri),
("client_id", &self.config.client_id),
("code_verifier", code_verifier),
]);
let response = self.client
.post(format!("{}/oauth/token", self.config.handle_resolver))
.form(&params)
.send()
.await?;
if response.status().is_success() {
let token: TokenResponse = response.json().await?;
Ok(token)
} else {
anyhow::bail!("Failed to exchange authorization code")
}
}
pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
let params = HashMap::from([
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", &self.config.client_id),
]);
let response = self.client
.post(format!("{}/oauth/token", self.config.handle_resolver))
.form(&params)
.send()
.await?;
if response.status().is_success() {
let token: TokenResponse = response.json().await?;
Ok(token)
} else {
anyhow::bail!("Failed to refresh token")
}
}
}
// PKCE helpers
pub fn generate_code_verifier() -> String {
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
let mut rng = rand::thread_rng();
(0..128)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
pub fn generate_code_challenge(verifier: &str) -> String {
use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose};
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let result = hasher.finalize();
general_purpose::URL_SAFE_NO_PAD.encode(result)
}