diff --git a/Cargo.lock b/Cargo.lock index 9c91981..26d5d41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,10 +410,12 @@ dependencies = [ "jacquard-api", "jacquard-axum", "jacquard-common", + "jacquard-derive", "jacquard-identity", "log", "postcard", "reqwest", + "rustversion", "serde", "serde_json", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index d0def80..2899b81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,13 @@ jacquard-api = { path = "../jacquard/crates/jacquard-api", features = ["default" jacquard-axum = { path = "../jacquard/crates/jacquard-axum" } # jacquard-common = "0.11.0" jacquard-common = { path = "../jacquard/crates/jacquard-common", features = ["websocket", "streaming", "reqwest-client"] } +jacquard-derive = { path = "../jacquard/crates/jacquard-derive" } # jacquard-identity = { version = "0.11.0", features = ["cache", "dns"] } jacquard-identity = { path = "../jacquard/crates/jacquard-identity", features = ["cache", "dns"] } log = "0.4.29" postcard = { version = "1", features = ["alloc"] } reqwest = { version = "0.12.23", features = ["stream", "json"] } +rustversion = "1.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" thiserror = "2" diff --git a/README.md b/README.md index 782e8f2..b50e58e 100644 --- a/README.md +++ b/README.md @@ -4,5 +4,8 @@ Ceres is a Bluesky AppView that aims to be easy on resources, does most of what # Work done currently - `app.bsky.actor.getProfile` - Just started, and let me tell you. It got hands + - Should be pulling in everything from the profile lexicon + - Pulls in followers since it's easy from constellation + - Other things like labels, etc are not done yet - `app.bsky.actor.getPreferences` - Works, but not as expected. It has it's own internal preferences. Not the ones from your PDS. best to just use a social app that clears the atproto-proxy to get from your own PDS - `app.bsky.actor.putPreferences` - [lol](https://github.com/bluesky-social/atproto/issues/4193). Best to just use a bsky-social app fork like https://blacksky.community for running this AppView diff --git a/lexicons/blue/microcosm/links/getBacklinkDids.json b/lexicons/blue/microcosm/links/getBacklinkDids.json new file mode 100644 index 0000000..3d196ef --- /dev/null +++ b/lexicons/blue/microcosm/links/getBacklinkDids.json @@ -0,0 +1,62 @@ +{ + "defs": { + "main": { + "description": "a list of distinct dids with a specific records linking to a target at a specified path", + "output": { + "encoding": "application/json", + "schema": { + "properties": { + "cursor": { + "description": "pagination cursor", + "type": "string" + }, + "linking_dids": { + "items": { + "format": "did", + "type": "string" + }, + "type": "array" + }, + "total": { + "description": "total number of matching links", + "type": "integer" + } + }, + "required": [ + "total", + "linking_dids" + ], + "type": "object" + } + }, + "parameters": { + "properties": { + "limit": { + "default": 16, + "description": "number of results to return", + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "source": { + "description": "collection and path specification (e.g., 'app.bsky.feed.like:subject.uri')", + "type": "string" + }, + "subject": { + "description": "the target being linked to (at-uri, did, or uri)", + "format": "uri", + "type": "string" + } + }, + "required": [ + "subject", + "source" + ], + "type": "params" + }, + "type": "query" + } + }, + "id": "blue.microcosm.links.getBacklinkDids", + "lexicon": 1 +} diff --git a/src/constellation.rs b/src/constellation.rs new file mode 100644 index 0000000..7919d67 --- /dev/null +++ b/src/constellation.rs @@ -0,0 +1,61 @@ +use anyhow::Context; +use jacquard::IntoStatic; +use jacquard::common::xrpc::XrpcEndpoint; +use reqwest::Client; + +use crate::lexicons::blue_microcosm::links::get_backlink_dids::{ + GetBacklinkDidsOutput, GetBacklinkDidsRequest, +}; + +pub async fn get_backlink_dids( + client: &Client, + host: &str, + subject: &str, + source: &str, + limit: Option, + cursor: Option<&str>, +) -> anyhow::Result> { + let url = format!( + "{}{}", + host.trim_end_matches('/'), + GetBacklinkDidsRequest::PATH, + ); + + let mut query: Vec<(&str, String)> = Vec::with_capacity(4); + query.push(("subject", subject.to_string())); + query.push(("source", source.to_string())); + if let Some(limit) = limit { + query.push(("limit", limit.to_string())); + } + if let Some(cursor) = cursor { + query.push(("cursor", cursor.to_string())); + } + + let resp = client + .get(&url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::ACCEPT, "application/json") + .query(&query) + .send() + .await + .context("constellation getBacklinkDids request")?; + + let status = resp.status(); + let bytes = resp + .bytes() + .await + .context("constellation getBacklinkDids read")?; + + if !status.is_success() { + anyhow::bail!( + "constellation getBacklinkDids: status={} body={}", + status, + String::from_utf8_lossy(&bytes) + ); + } + + let parsed: GetBacklinkDidsOutput<'_> = + serde_json::from_slice(&bytes).context("constellation getBacklinkDids parse")?; + + Ok(parsed.into_static()) +} diff --git a/src/lexicons/blue_microcosm.rs b/src/lexicons/blue_microcosm.rs new file mode 100644 index 0000000..9eb876d --- /dev/null +++ b/src/lexicons/blue_microcosm.rs @@ -0,0 +1,6 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +pub mod links; \ No newline at end of file diff --git a/src/lexicons/blue_microcosm/links.rs b/src/lexicons/blue_microcosm/links.rs new file mode 100644 index 0000000..5abdf99 --- /dev/null +++ b/src/lexicons/blue_microcosm/links.rs @@ -0,0 +1,6 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +pub mod get_backlink_dids; \ No newline at end of file diff --git a/src/lexicons/blue_microcosm/links/get_backlink_dids.rs b/src/lexicons/blue_microcosm/links/get_backlink_dids.rs new file mode 100644 index 0000000..3b758ed --- /dev/null +++ b/src/lexicons/blue_microcosm/links/get_backlink_dids.rs @@ -0,0 +1,213 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: blue.microcosm.links.getBacklinkDids +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[derive( + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + PartialEq, + Eq, + jacquard_derive::IntoStatic +)] +#[serde(rename_all = "camelCase")] +pub struct GetBacklinkDids<'a> { + ///(default: 16, min: 1, max: 100) + #[serde(skip_serializing_if = "std::option::Option::is_none")] + pub limit: std::option::Option, + #[serde(borrow)] + pub source: jacquard_common::CowStr<'a>, + #[serde(borrow)] + pub subject: jacquard_common::types::string::UriValue<'a>, +} + +pub mod get_backlink_dids_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Subject; + type Source; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Subject = Unset; + type Source = Unset; + } + ///State transition - sets the `subject` field to Set + pub struct SetSubject(PhantomData S>); + impl sealed::Sealed for SetSubject {} + impl State for SetSubject { + type Subject = Set; + type Source = S::Source; + } + ///State transition - sets the `source` field to Set + pub struct SetSource(PhantomData S>); + impl sealed::Sealed for SetSource {} + impl State for SetSource { + type Subject = S::Subject; + type Source = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `subject` field + pub struct subject(()); + ///Marker type for the `source` field + pub struct source(()); + } +} + +/// Builder for constructing an instance of this type +pub struct GetBacklinkDidsBuilder<'a, S: get_backlink_dids_state::State> { + _phantom_state: ::core::marker::PhantomData S>, + __unsafe_private_named: ( + ::core::option::Option, + ::core::option::Option>, + ::core::option::Option>, + ), + _phantom: ::core::marker::PhantomData<&'a ()>, +} + +impl<'a> GetBacklinkDids<'a> { + /// Create a new builder for this type + pub fn new() -> GetBacklinkDidsBuilder<'a, get_backlink_dids_state::Empty> { + GetBacklinkDidsBuilder::new() + } +} + +impl<'a> GetBacklinkDidsBuilder<'a, get_backlink_dids_state::Empty> { + /// Create a new builder with all fields unset + pub fn new() -> Self { + GetBacklinkDidsBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: (None, None, None), + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S: get_backlink_dids_state::State> GetBacklinkDidsBuilder<'a, S> { + /// Set the `limit` field (optional) + pub fn limit(mut self, value: impl Into>) -> Self { + self.__unsafe_private_named.0 = value.into(); + self + } + /// Set the `limit` field to an Option value (optional) + pub fn maybe_limit(mut self, value: Option) -> Self { + self.__unsafe_private_named.0 = value; + self + } +} + +impl<'a, S> GetBacklinkDidsBuilder<'a, S> +where + S: get_backlink_dids_state::State, + S::Source: get_backlink_dids_state::IsUnset, +{ + /// Set the `source` field (required) + pub fn source( + mut self, + value: impl Into>, + ) -> GetBacklinkDidsBuilder<'a, get_backlink_dids_state::SetSource> { + self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); + GetBacklinkDidsBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> GetBacklinkDidsBuilder<'a, S> +where + S: get_backlink_dids_state::State, + S::Subject: get_backlink_dids_state::IsUnset, +{ + /// Set the `subject` field (required) + pub fn subject( + mut self, + value: impl Into>, + ) -> GetBacklinkDidsBuilder<'a, get_backlink_dids_state::SetSubject> { + self.__unsafe_private_named.2 = ::core::option::Option::Some(value.into()); + GetBacklinkDidsBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + +impl<'a, S> GetBacklinkDidsBuilder<'a, S> +where + S: get_backlink_dids_state::State, + S::Subject: get_backlink_dids_state::IsSet, + S::Source: get_backlink_dids_state::IsSet, +{ + /// Build the final struct + pub fn build(self) -> GetBacklinkDids<'a> { + GetBacklinkDids { + limit: self.__unsafe_private_named.0, + source: self.__unsafe_private_named.1.unwrap(), + subject: self.__unsafe_private_named.2.unwrap(), + } + } +} + +#[jacquard_derive::lexicon] +#[derive( + serde::Serialize, + serde::Deserialize, + Debug, + Clone, + PartialEq, + Eq, + jacquard_derive::IntoStatic +)] +pub struct GetBacklinkDidsOutput<'a> { + /// pagination cursor + #[serde(skip_serializing_if = "std::option::Option::is_none")] + #[serde(borrow)] + pub cursor: std::option::Option>, + #[serde(borrow)] + pub linking_dids: Vec>, + /// total number of matching links + pub total: i64, +} + +/// Response type for +///blue.microcosm.links.getBacklinkDids +pub struct GetBacklinkDidsResponse; +impl jacquard_common::xrpc::XrpcResp for GetBacklinkDidsResponse { + const NSID: &'static str = "blue.microcosm.links.getBacklinkDids"; + const ENCODING: &'static str = "application/json"; + type Output<'de> = GetBacklinkDidsOutput<'de>; + type Err<'de> = jacquard_common::xrpc::GenericError<'de>; +} + +impl<'a> jacquard_common::xrpc::XrpcRequest for GetBacklinkDids<'a> { + const NSID: &'static str = "blue.microcosm.links.getBacklinkDids"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetBacklinkDidsResponse; +} + +/// Endpoint type for +///blue.microcosm.links.getBacklinkDids +pub struct GetBacklinkDidsRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetBacklinkDidsRequest { + const PATH: &'static str = "/xrpc/blue.microcosm.links.getBacklinkDids"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request<'de> = GetBacklinkDids<'de>; + type Response = GetBacklinkDidsResponse; +} \ No newline at end of file diff --git a/src/lexicons/builder_types.rs b/src/lexicons/builder_types.rs new file mode 100644 index 0000000..5deeb86 --- /dev/null +++ b/src/lexicons/builder_types.rs @@ -0,0 +1,43 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +/// Marker type indicating a builder field has been set +pub struct Set(pub T); +impl Set { + /// Extract the inner value + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + +/// Marker type indicating a builder field has not been set +pub struct Unset; +/// Trait indicating a builder field is set (has a value) +#[rustversion::attr( + since(1.78.0), + diagnostic::on_unimplemented( + message = "the field `{Self}` was not set, but this method requires it to be set", + label = "the field `{Self}` was not set" + ) +)] +pub trait IsSet: private::Sealed {} +/// Trait indicating a builder field is unset (no value yet) +#[rustversion::attr( + since(1.78.0), + diagnostic::on_unimplemented( + message = "the field `{Self}` was already set, but this method requires it to be unset", + label = "the field `{Self}` was already set" + ) +)] +pub trait IsUnset: private::Sealed {} +impl IsSet for Set {} +impl IsUnset for Unset {} +mod private { + /// Sealed trait to prevent external implementations + pub trait Sealed {} + impl Sealed for super::Set {} + impl Sealed for super::Unset {} +} \ No newline at end of file diff --git a/src/lexicons/mod.rs b/src/lexicons/mod.rs new file mode 100644 index 0000000..065fc3a --- /dev/null +++ b/src/lexicons/mod.rs @@ -0,0 +1,2 @@ +pub mod blue_microcosm; +pub mod builder_types; diff --git a/src/main.rs b/src/main.rs index 39c3d9a..30ac722 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +extern crate alloc; + use std::env; use std::net::SocketAddr; use std::path::Path; @@ -17,12 +19,16 @@ use tokio_util::sync::CancellationToken; use crate::error::{Error, Result}; use crate::state::AppState; +mod constellation; mod error; +mod lexicons; mod server; mod state; mod storage; mod sync; +pub use crate::lexicons::builder_types; + static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); #[tokio::main] @@ -66,12 +72,16 @@ async fn run() -> Result<()> { let data_dir = env::var("CERES_DATA_DIRECTORY").unwrap_or_else(|_| ".ceres_data".to_string()); let db = storage::open(Path::new(&data_dir))?; + let constellation_host = env::var("CONSTELLATION_HOST") + .unwrap_or_else(|_| "https://constellation.microcosm.blue".to_string()); + let state = AppState { service_auth, reqwest_client, agent, resolver, forwarded_app_view: env::var("FORWARDED_APP_VIEW").ok(), + constellation_host, db: db.clone(), }; diff --git a/src/server/xrpc/app_bsky_actor.rs b/src/server/xrpc/app_bsky_actor.rs index aecb41d..9461297 100644 --- a/src/server/xrpc/app_bsky_actor.rs +++ b/src/server/xrpc/app_bsky_actor.rs @@ -1,17 +1,14 @@ -use std::str::FromStr; -use std::time::SystemTime; - +use crate::constellation; +use crate::server::xrpc::XrpcErrorResponse; use crate::state::AppState; use crate::storage; -use crate::{server::xrpc::XrpcErrorResponse, sync::backfill::BackfillJob}; -use axum::Form; use axum::{Json, Router, extract::State}; use jacquard::client::AgentSessionExt; use jacquard::types::aturi::AtUri; use jacquard::{ IntoStatic, prelude::IdentityResolver, - types::{datetime::Datetime, ident::AtIdentifier, uri::UriValue}, + types::{datetime::Datetime, did::Did, ident::AtIdentifier, uri::UriValue}, }; use jacquard_api::app_bsky::actor::get_profiles::{GetProfilesOutput, GetProfilesRequest}; use jacquard_api::app_bsky::actor::{ @@ -23,6 +20,26 @@ use jacquard_api::app_bsky::actor::{ }; 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( + &state.reqwest_client, + &state.constellation_host, + did.as_ref(), + "app.bsky.graph.follow:subject", + None, + None, + ) + .await + { + Ok(out) => Some(out.total), + Err(err) => { + log::warn!("constellation followers fetch failed: {err:#}"); + None + } + } +} async fn resolve_profile_view( state: &AppState, @@ -110,6 +127,8 @@ async fn resolve_profile_view( XrpcErrorResponse::internal_server_error() })?; + let followers_count = fetch_followers_count(state, &did).await; + Ok(ProfileViewDetailed { associated: None, avatar, @@ -119,7 +138,7 @@ async fn resolve_profile_view( description: profile_record.description, did, display_name: profile_record.display_name, - followers_count: Some(67), + followers_count, follows_count: Some(67), handle, indexed_at: Some(Datetime::now()), diff --git a/src/state.rs b/src/state.rs index 6fe177d..235dc3a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -16,6 +16,7 @@ pub struct AppState { pub agent: Arc, pub resolver: JacquardResolver, pub forwarded_app_view: Option, + pub constellation_host: String, pub db: DbRef, }