update
This commit is contained in:
108
src/atproto/client.rs
Normal file
108
src/atproto/client.rs
Normal 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
120
src/atproto/comment_sync.rs
Normal 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
7
src/atproto/mod.rs
Normal 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
162
src/atproto/oauth.rs
Normal 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(¶ms)
|
||||
.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(¶ms)
|
||||
.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)
|
||||
}
|
Reference in New Issue
Block a user