diff --git a/src/server/mod.rs b/src/server/mod.rs index bb7dc4a..4dd963b 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -21,6 +21,7 @@ pub async fn serve( let app: Router = Router::new() .route("/.well-known/did.json", get(well_known::did_document)) .merge(xrpc::app_bsky_actor::routes()) + .merge(xrpc::app_bsky_feed::routes()) .fallback(fallback::log_request) .layer(CorsLayer::permissive()) .with_state(state); diff --git a/src/server/xrpc/app_bsky_actor.rs b/src/server/xrpc/app_bsky_actor.rs index 9461297..2787ff6 100644 --- a/src/server/xrpc/app_bsky_actor.rs +++ b/src/server/xrpc/app_bsky_actor.rs @@ -1,26 +1,21 @@ use crate::constellation; -use crate::server::xrpc::XrpcErrorResponse; +use crate::server::xrpc::{XrpcErrorResponse, resolve_author_context}; use crate::state::AppState; use crate::storage; use axum::{Json, Router, extract::State}; -use jacquard::client::AgentSessionExt; -use jacquard::types::aturi::AtUri; use jacquard::{ IntoStatic, - prelude::IdentityResolver, - types::{datetime::Datetime, did::Did, ident::AtIdentifier, uri::UriValue}, + types::{datetime::Datetime, did::Did, ident::AtIdentifier}, }; use jacquard_api::app_bsky::actor::get_profiles::{GetProfilesOutput, GetProfilesRequest}; use jacquard_api::app_bsky::actor::{ PreferencesItem, ProfileViewDetailed, get_preferences::{GetPreferencesOutput, GetPreferencesRequest}, get_profile::{GetProfileOutput, GetProfileRequest}, - profile::Profile, put_preferences::PutPreferencesRequest, }; use jacquard_axum::{ExtractXrpc, IntoRouter, service_auth::ExtractOptionalServiceAuth}; use log::info; -use std::str::FromStr; async fn fetch_followers_count(state: &AppState, did: &Did<'_>) -> Option { match constellation::get_backlink_dids( @@ -45,112 +40,31 @@ async fn resolve_profile_view( state: &AppState, actor: AtIdentifier<'_>, ) -> Result, XrpcErrorResponse> { - let did = match actor { - AtIdentifier::Did(did) => did.into_static(), - AtIdentifier::Handle(handle) => { - state - .resolver - .resolve_handle(&handle) - .await - .map_err(|err| { - log::error!("Error resolving the handle to a did for: {handle}"); - log::error!("{err}"); - XrpcErrorResponse::internal_server_error() - })? - } - }; - - let requested_did_doc = state.resolver.resolve_did_doc(&did).await.map_err(|err| { - log::error!("{err}"); - XrpcErrorResponse::internal_server_error() - })?; - - let did_doc = requested_did_doc.parse().map_err(|err| { - log::error!("{err}"); - XrpcErrorResponse::internal_server_error() - })?; - - let handle = did_doc.handles().get(0).cloned().ok_or_else(|| { - log::error!("No handle found in DID document"); - XrpcErrorResponse::internal_server_error() - })?; - - let pds_url = did_doc.pds_endpoint().ok_or_else(|| { - log::error!("No PDS URL found in DID document"); - XrpcErrorResponse::internal_server_error() - })?; - - let at_uri = AtUri::from_str(format!("at://{did}/app.bsky.actor.profile/self").as_str()) - .map_err(|err| { - log::error!("{err}"); - XrpcErrorResponse::internal_server_error() - })?; - let result = state - .agent - .fetch_record_slingshot(&at_uri) - .await - .map_err(|err| { - log::error!("{err}"); - XrpcErrorResponse::internal_server_error() - })?; - let profile_record: Profile<'static> = jacquard::common::from_data_owned(result.value) - .map_err(|err| { - log::error!("parse profile record: {err}"); - XrpcErrorResponse::internal_server_error() - })?; - - let avatar = profile_record - .avatar - .map(|blob_ref| { - UriValue::new_owned(format!( - "{pds_url}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}", - cid = blob_ref.blob().cid() - )) - }) - .transpose() - .map_err(|err| { - log::error!("avatar uri: {err}"); - XrpcErrorResponse::internal_server_error() - })?; - - let banner = profile_record - .banner - .map(|blob_ref| { - UriValue::new_owned(format!( - "{pds_url}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}", - cid = blob_ref.blob().cid() - )) - }) - .transpose() - .map_err(|err| { - log::error!("banner uri: {err}"); - XrpcErrorResponse::internal_server_error() - })?; - - let followers_count = fetch_followers_count(state, &did).await; + let ctx = resolve_author_context(state, actor).await?; + let followers_count = fetch_followers_count(state, &ctx.did).await; Ok(ProfileViewDetailed { associated: None, - avatar, - banner, - created_at: profile_record.created_at, + avatar: ctx.avatar_uri, + banner: ctx.banner_uri, + created_at: ctx.profile.created_at, debug: None, - description: profile_record.description, - did, - display_name: profile_record.display_name, + description: ctx.profile.description, + did: ctx.did, + display_name: ctx.profile.display_name, followers_count, follows_count: Some(67), - handle, + handle: ctx.handle, indexed_at: Some(Datetime::now()), joined_via_starter_pack: None, labels: None, - pinned_post: profile_record.pinned_post, + pinned_post: ctx.profile.pinned_post, posts_count: None, - pronouns: profile_record.pronouns, + pronouns: ctx.profile.pronouns, status: None, verification: None, viewer: None, - website: profile_record.website, + website: ctx.profile.website, extra_data: Default::default(), }) } diff --git a/src/server/xrpc/app_bsky_feed.rs b/src/server/xrpc/app_bsky_feed.rs new file mode 100644 index 0000000..65b41c0 --- /dev/null +++ b/src/server/xrpc/app_bsky_feed.rs @@ -0,0 +1,121 @@ +use crate::server::xrpc::{XrpcErrorResponse, resolve_author_context}; +use crate::state::AppState; +use axum::{Json, Router, extract::State}; +use jacquard::{ + prelude::{HttpClient, XrpcClient}, + types::{datetime::Datetime, ident::AtIdentifier, string::Nsid}, +}; +use jacquard_api::app_bsky::actor::ProfileViewBasic; +use jacquard_api::app_bsky::feed::get_author_feed::{GetAuthorFeedOutput, GetAuthorFeedRequest}; +use jacquard_api::app_bsky::feed::{FeedViewPost, PostView}; +use jacquard_api::com_atproto::repo::list_records::{ListRecords, ListRecordsResponse}; +use jacquard_axum::{ExtractXrpc, IntoRouter, service_auth::ExtractOptionalServiceAuth}; +use jacquard_common::xrpc; +use log::{debug, info}; + +pub async fn get_author_feed( + State(state): State, + ExtractOptionalServiceAuth(_auth): ExtractOptionalServiceAuth, + ExtractXrpc(req): ExtractXrpc, +) -> Result>, XrpcErrorResponse> { + info!( + "get_author_feed actor={} limit={:?}", + req.actor.as_ref(), + req.limit + ); + debug!( + "get_author_feed filter={:?} include_pins={:?} cursor={:?}", + req.filter, req.include_pins, req.cursor + ); + + let ctx = resolve_author_context(&state, req.actor).await?; + + let author = ProfileViewBasic { + associated: None, + avatar: ctx.avatar_uri.clone(), + created_at: ctx.profile.created_at, + debug: None, + did: ctx.did.clone(), + display_name: ctx.profile.display_name.clone(), + handle: ctx.handle.clone(), + labels: None, + pronouns: ctx.profile.pronouns.clone(), + status: None, + verification: None, + viewer: None, + extra_data: Default::default(), + }; + + let post_collection = + Nsid::new_static("app.bsky.feed.post").expect("app.bsky.feed.post is a valid nsid"); + let list_req = ListRecords::new() + .repo(AtIdentifier::Did(ctx.did.clone())) + .collection(post_collection) + .maybe_limit(req.limit) + .maybe_cursor(req.cursor) + .build(); + + let opts = state.agent.opts().await; + let http_request = xrpc::build_http_request(&ctx.pds_url, &list_req, &opts).map_err(|err| { + log::error!("build listRecords request: {err}"); + XrpcErrorResponse::internal_server_error() + })?; + let http_response = state.agent.send_http(http_request).await.map_err(|err| { + log::error!("send listRecords: {err}"); + XrpcErrorResponse::internal_server_error() + })?; + let response: xrpc::Response = xrpc::process_response(http_response) + .map_err(|err| { + log::error!("process listRecords response: {err}"); + XrpcErrorResponse::internal_server_error() + })?; + let output = response.into_output().map_err(|err| { + log::error!("parse listRecords output: {err}"); + XrpcErrorResponse::internal_server_error() + })?; + + let feed = output + .records + .into_iter() + .map(|record| { + let post = PostView { + author: author.clone(), + bookmark_count: None, + cid: record.cid, + debug: None, + embed: None, + indexed_at: Datetime::now(), + labels: None, + like_count: None, + quote_count: None, + record: record.value, + reply_count: None, + repost_count: None, + threadgate: None, + uri: record.uri, + viewer: None, + extra_data: Default::default(), + }; + FeedViewPost { + feed_context: None, + post, + reason: None, + reply: None, + req_id: None, + extra_data: Default::default(), + } + }) + .collect(); + + Ok(Json(GetAuthorFeedOutput { + cursor: output.cursor, + feed, + extra_data: Default::default(), + })) +} + +pub fn routes() -> Router { + Router::::new().merge(GetAuthorFeedRequest::into_router::<_, AppState, _>( + get_author_feed, + )) +} diff --git a/src/server/xrpc/mod.rs b/src/server/xrpc/mod.rs index f62a77d..729da6e 100644 --- a/src/server/xrpc/mod.rs +++ b/src/server/xrpc/mod.rs @@ -1,10 +1,23 @@ +use std::str::FromStr; + use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, }; +use jacquard::{ + IntoStatic, + client::AgentSessionExt, + deps::fluent_uri::Uri, + prelude::IdentityResolver, + types::{aturi::AtUri, did::Did, ident::AtIdentifier, string::Handle, uri::UriValue}, +}; +use jacquard_api::app_bsky::actor::profile::Profile; + +use crate::state::AppState; pub mod app_bsky_actor; +pub mod app_bsky_feed; pub struct XrpcErrorResponse { error: XrpcError, @@ -55,3 +68,112 @@ impl IntoResponse for XrpcErrorResponse { (self.status, Json(self.error)).into_response() } } + +//Author things + +pub(crate) struct AuthorContext { + pub did: Did<'static>, + pub handle: Handle<'static>, + pub pds_url: Uri, + pub profile: Profile<'static>, + pub avatar_uri: Option>, + pub banner_uri: Option>, +} + +pub(crate) async fn resolve_author_context( + state: &AppState, + actor: AtIdentifier<'_>, +) -> Result { + let did = match actor { + AtIdentifier::Did(did) => did.into_static(), + AtIdentifier::Handle(handle) => { + state + .resolver + .resolve_handle(&handle) + .await + .map_err(|err| { + log::error!("Error resolving the handle to a did for: {handle}"); + log::error!("{err}"); + XrpcErrorResponse::internal_server_error() + })? + } + }; + + let requested_did_doc = state.resolver.resolve_did_doc(&did).await.map_err(|err| { + log::error!("{err}"); + XrpcErrorResponse::internal_server_error() + })?; + + let did_doc = requested_did_doc.parse().map_err(|err| { + log::error!("{err}"); + XrpcErrorResponse::internal_server_error() + })?; + + let handle = did_doc.handles().get(0).cloned().ok_or_else(|| { + log::error!("No handle found in DID document"); + XrpcErrorResponse::internal_server_error() + })?; + + let pds_url = did_doc.pds_endpoint().ok_or_else(|| { + log::error!("No PDS URL found in DID document"); + XrpcErrorResponse::internal_server_error() + })?; + + let at_uri = AtUri::from_str(format!("at://{did}/app.bsky.actor.profile/self").as_str()) + .map_err(|err| { + log::error!("{err}"); + XrpcErrorResponse::internal_server_error() + })?; + let result = state + .agent + .fetch_record_slingshot(&at_uri) + .await + .map_err(|err| { + log::error!("{err}"); + XrpcErrorResponse::internal_server_error() + })?; + let profile: Profile<'static> = + jacquard::common::from_data_owned(result.value).map_err(|err| { + log::error!("parse profile record: {err}"); + XrpcErrorResponse::internal_server_error() + })?; + + let avatar_uri = profile + .avatar + .as_ref() + .map(|blob_ref| { + UriValue::new_owned(format!( + "{pds_url}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}", + cid = blob_ref.blob().cid() + )) + }) + .transpose() + .map_err(|err| { + log::error!("avatar uri: {err}"); + XrpcErrorResponse::internal_server_error() + })?; + + let banner_uri = profile + .banner + .as_ref() + .map(|blob_ref| { + UriValue::new_owned(format!( + "{pds_url}/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}", + cid = blob_ref.blob().cid() + )) + }) + .transpose() + .map_err(|err| { + log::error!("banner uri: {err}"); + XrpcErrorResponse::internal_server_error() + })?; + + Ok(AuthorContext { + did, + handle, + pds_url, + profile, + avatar_uri, + banner_uri, + }) +}