feat: add bsky and bsky-support cargo features to manage bsky specific code

This commit is contained in:
nelind
2026-07-25 18:31:06 +03:00
committed by Tangled
parent 34a47e6e5a
commit 59934cc184
15 changed files with 135 additions and 66 deletions
+4
View File
@@ -47,3 +47,7 @@ tracing = { workspace = true }
urlencoding = { workspace = true }
uuid = { workspace = true }
webauthn-rs = { workspace = true }
[features]
bsky = ["bsky-support"]
bsky-support = []
-3
View File
@@ -1,3 +0,0 @@
mod preferences;
pub use preferences::{get_preferences, put_preferences};
+17 -13
View File
@@ -205,20 +205,24 @@ pub async fn sequence_new_account(
{
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
}
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": display_name
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
state,
did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
// TODO: make this configurable and also deduplicate with tranquil-oauth-server/src/sso_endpoints.rs:1210
#[cfg(feature = "bsky")]
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": display_name
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
state,
did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
};
}
}
+26 -11
View File
@@ -1,5 +1,12 @@
pub mod actor;
// BSKY: Bluesky requires PDSs to implement its private preferences API
#[cfg(feature = "bsky-support")]
pub mod actor {
mod preferences;
pub use preferences::{get_preferences, put_preferences};
}
pub mod admin;
#[cfg(feature = "bsky")]
pub mod age_assurance;
pub mod common;
pub mod delegation;
@@ -21,7 +28,7 @@ pub fn api_routes() -> axum::Router<AppState> {
let blob_body_limit =
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
axum::Router::new()
let router = axum::Router::new()
.route("/_health", get(server::health))
.route(
"/com.atproto.server.describeServer",
@@ -373,14 +380,6 @@ pub fn api_routes() -> axum::Router<AppState> {
post(admin::update_subject_status),
)
.route("/com.atproto.admin.sendEmail", post(admin::send_email))
.route(
"/app.bsky.actor.getPreferences",
get(actor::get_preferences),
)
.route(
"/app.bsky.actor.putPreferences",
post(actor::put_preferences),
)
.route(
"/com.atproto.temp.checkSignupQueue",
get(temp::check_signup_queue),
@@ -438,7 +437,21 @@ pub fn api_routes() -> axum::Router<AppState> {
.route(
"/_delegation.resolveController",
get(delegation::resolve_controller),
);
#[cfg(feature = "bsky-support")]
let router = router
.route(
"/app.bsky.actor.getPreferences",
get(actor::get_preferences),
)
.route(
"/app.bsky.actor.putPreferences",
post(actor::put_preferences),
);
#[cfg(feature = "bsky")]
let router = router
.route(
"/app.bsky.ageassurance.getState",
get(age_assurance::get_state),
@@ -446,7 +459,9 @@ pub fn api_routes() -> axum::Router<AppState> {
.route(
"/app.bsky.unspecced.getAgeAssuranceState",
get(age_assurance::get_age_assurance_state),
)
);
router
}
pub fn well_known_api_routes() -> axum::Router<AppState> {
+3
View File
@@ -37,3 +37,6 @@ webauthn-rs = { workspace = true }
[dev-dependencies]
async-trait = { workspace = true }
[features]
bsky = []
@@ -1209,20 +1209,23 @@ pub async fn complete_registration(
tracing::warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle.as_str()
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
#[cfg(feature = "bsky")]
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle.as_str()
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
};
}
let app_password = generate_app_password();
+2
View File
@@ -76,6 +76,8 @@ webauthn-rs = { workspace = true }
webauthn-rs-proto = { workspace = true }
[features]
bsky = ["bsky-support"]
bsky-support = []
external-infra = []
postgres = ["tranquil-db/postgres"]
s3 = ["tranquil-storage/s3"]
+30 -23
View File
@@ -19,9 +19,7 @@ use tower::{Service, util::BoxCloneSyncService};
use tracing::{error, info, warn};
static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"app.bsky.actor.getPreferences",
"app.bsky.actor.putPreferences",
let mut methods: HashSet<&str> = [
"com.atproto.admin.deleteAccount",
"com.atproto.admin.disableAccountInvites",
"com.atproto.admin.disableInviteCodes",
@@ -103,7 +101,13 @@ static PROTECTED_METHODS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
"com.atproto.temp.dereferenceScope",
]
.into_iter()
.collect()
.collect();
// BSKY: the Bluesky preferences API must be implemented by PDSs
if cfg!(feature = "bsky-support") {
methods.insert("app.bsky.actor.getPreferences");
methods.insert("app.bsky.actor.putPreferences");
};
methods
});
fn is_protected_method(method: &str) -> bool {
@@ -111,6 +115,7 @@ fn is_protected_method(method: &str) -> bool {
}
/// Fetch the `feed` generator record from the AppView and return its `did`.
#[cfg(feature = "bsky-support")]
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<Did> {
#[derive(serde::Deserialize)]
struct GetFeedQuery {
@@ -201,6 +206,7 @@ impl<S: Service<Request, Response = Response, Error = Infallible>> Service<Reque
}
// If the age assurance override is set and this is an age assurance call then we dont want to proxy even if the client requests it
#[cfg(feature = "bsky")]
if tranquil_config::get().server.age_assurance_override
&& (path.ends_with("app.bsky.ageassurance.getState")
|| path.ends_with("app.bsky.unspecced.getAgeAssuranceState"))
@@ -328,27 +334,28 @@ async fn proxy_handler(
},
};
// getFeed must be audienced to the feed generator, not the AppView.
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
Some(feed_did) => (
feed_did,
"app.bsky.feed.getFeedSkeleton"
.parse::<Nsid>()
.expect("getFeedSkeleton is a valid NSID"),
),
None => {
warn!(
"getFeed proxy: could not resolve feed generator DID; refusing \
// BSKY: getFeed must be audienced to the feed generator, not the AppView.
let (token_aud, token_lxm) =
if cfg!(feature = "bsky-support") && method == "app.bsky.feed.getFeed" {
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
Some(feed_did) => (
feed_did,
"app.bsky.feed.getFeedSkeleton"
.parse::<Nsid>()
.expect("getFeedSkeleton is a valid NSID"),
),
None => {
warn!(
"getFeed proxy: could not resolve feed generator DID; refusing \
to mint an AppView-audienced token"
);
return ApiError::InvalidRequest("Could not resolve feed".into())
.into_response();
);
return ApiError::InvalidRequest("Could not resolve feed".into())
.into_response();
}
}
}
} else {
(resolved.did.clone(), method_nsid.clone())
};
} else {
(resolved.did.clone(), method_nsid.clone())
};
match crate::auth::create_service_token(
&auth_user.did,
+11 -1
View File
@@ -147,7 +147,9 @@ impl std::fmt::Display for SsrfError {
}
impl std::error::Error for SsrfError {}
// TODO: update to match https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/pipethrough.ts
// Currently spec says nothing at all!! about forwarding headers during proxying and https://github.com/bluesky-social/atproto/discussions/2350
#[cfg(feature = "bsky-support")]
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
[
HeaderName::from_static("accept-language"),
@@ -156,6 +158,14 @@ pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 4]> = LazyLock::new(|| {
http::header::CONTENT_TYPE,
]
});
#[cfg(not(feature = "bsky-support"))]
pub static HEADERS_TO_FORWARD: LazyLock<[HeaderName; 3]> = LazyLock::new(|| {
[
HeaderName::from_static("accept-language"),
crate::util::HEADER_ATPROTO_ACCEPT_LABELERS,
http::header::CONTENT_TYPE,
]
});
pub static RESPONSE_HEADERS_TO_FORWARD: LazyLock<[HeaderName; 6]> = LazyLock::new(|| {
[
crate::util::HEADER_ATPROTO_REPO_REV,
@@ -1,6 +1,9 @@
use std::collections::HashSet;
use std::sync::LazyLock;
// TODO: make all of this configurable.
// PDS implementation should not impose any reserved domains imo.
// some of these are even bad to have as defaults let alone non-configurables
const ATP_SPECIFIC: &[&str] = &[
"at", "atp", "plc", "pds", "did", "repo", "tid", "nsid", "xrpc", "lex", "lexicon", "bsky",
"bluesky", "handle",
+4
View File
@@ -115,6 +115,10 @@ pub fn extract_blob_cids(record: &Value) -> Vec<crate::types::CidLink> {
use crate::types::AtUri;
use tranquil_db_traits::{Backlink, BacklinkPath};
// TODO: it really really should not be necessary to extract backlinks and store those.
// especially not in a way that isnt generic.
// figure out what the fuck is going on here
// (lewis do you remember why you did this???)
pub fn extract_backlinks(uri: &AtUri, record: &Value) -> Vec<Backlink> {
let record_type = record
.get("$type")
+2
View File
@@ -2,6 +2,8 @@ pub use tranquil_types::*;
use std::sync::LazyLock;
#[cfg(feature = "bsky")]
pub static PROFILE_COLLECTION: LazyLock<Nsid> =
LazyLock::new(|| "app.bsky.actor.profile".parse().unwrap());
#[cfg(feature = "bsky")]
pub static PROFILE_RKEY: LazyLock<Rkey> = LazyLock::new(|| "self".parse().unwrap());
+8 -1
View File
@@ -87,6 +87,7 @@ pub const HEADER_ATPROTO_ACCEPT_LABELERS: HeaderName =
pub const HEADER_ATPROTO_REPO_REV: HeaderName = HeaderName::from_static("atproto-repo-rev");
pub const HEADER_ATPROTO_CONTENT_LABELERS: HeaderName =
HeaderName::from_static("atproto-content-labelers");
#[cfg(feature = "bsky-support")]
pub const HEADER_X_BSKY_TOPICS: HeaderName = HeaderName::from_static("x-bsky-topics");
pub fn get_header_str(
@@ -247,7 +248,13 @@ pub fn build_full_url(path: &str) -> String {
let cfg = tranquil_config::get();
let normalized_path = if !path.starts_with("/xrpc/")
&& (path.starts_with("/com.atproto.")
|| path.starts_with("/app.bsky.")
// BSKY: Bluesky requires that the PDS implement some app.bsky.* endpoints so we need to deal with those here too.
// TODO: surely we can figure out a way to do this more generically?
|| (if cfg!(feature = "bsky-support") {
path.starts_with("/app.bsky.")
} else {
true
})
|| path.starts_with("/_"))
{
format!("/xrpc{path}")
@@ -138,20 +138,25 @@ fn check_banned_content(
rkey: Option<&Rkey>,
) -> Result<(), ValidationError> {
match record_type {
#[cfg(feature = "bsky")]
"app.bsky.feed.post" => {
check_post_banned_content(obj)?;
}
#[cfg(feature = "bsky")]
"app.bsky.actor.profile" => {
check_string_field(obj, "displayName")?;
check_string_field(obj, "description")?;
}
#[cfg(feature = "bsky")]
"app.bsky.graph.list" => {
check_string_field(obj, "name")?;
}
#[cfg(feature = "bsky")]
"app.bsky.graph.starterpack" => {
check_string_field(obj, "name")?;
check_string_field(obj, "description")?;
}
#[cfg(feature = "bsky")]
"app.bsky.feed.generator" => {
if let Some(rkey) = rkey
&& crate::moderation::has_explicit_slur(rkey)
@@ -167,6 +172,7 @@ fn check_banned_content(
Ok(())
}
#[cfg(feature = "bsky")]
fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> {
if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) {
tags.iter().enumerate().try_for_each(|(i, tag)| {
+3 -1
View File
@@ -41,7 +41,9 @@ tracing-subscriber = { workspace = true }
rcgen = { workspace = true }
[features]
default = ["frontend", "postgres", "s3", "valkey"]
default = ["bsky", "frontend", "postgres", "s3", "valkey"]
bsky = ["bsky-support", "tranquil-pds/bsky", "tranquil-api/bsky"]
bsky-support = ["tranquil-pds/bsky-support", "tranquil-api/bsky-support"]
frontend = ["tranquil-pds/frontend"]
postgres = ["tranquil-pds/postgres"]
s3 = ["tranquil-pds/s3"]