Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bsky-read-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@germ-network/atprotoclient": minor
---

Add three Bluesky read helpers on `Atproto.XRPC.BskyAppCallable`: `relationshipLookup(actor:others:)`, which reports which `app.bsky.graph.getRelationships` subjects the AppView returned a relationship for and which it didn't (unlike `getRelationships(actor:subjects:)`, which drops every not-found subject and loses which ones they were) - note that this isn't an account-existence check, since the AppView returns a relationship entry for any well-formed DID whether or not the account exists; and `bskyProfileIfExists(actor:)`, which maps `app.bsky.actor.getProfile`'s undeclared not-found shape (a 400 `InvalidRequest` whose message is "Profile not found") to `nil` rather than throwing. Also add `Atproto.BskyCDN.imageURL(_:did:blob:host:)` to build Bluesky CDN image URLs from a blob reference.

Also fix `Lexicon.App.Bsky.Graph.GetRelationships.Parameters.init` to accept exactly 30 `others`, matching the lexicon's `others.maxLength: 30` - it previously rejected exactly 30, and make `Lexicon.App.Bsky.Graph.GetRelationships.Errors` (`tooManyOthersInput`, `actorMismatch`) public - it's already thrown by public API.
5 changes: 5 additions & 0 deletions .changeset/dns-wellknown-handle-resolvers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@germ-network/atprotoclient": minor
---

Add DNS-over-HTTPS TXT and well-known atproto-did handle-resolution components: `Atproto.WellKnownHandleResolver` and `Atproto.DnsHandleResolver`, plus the underlying `Atproto.DNSWireFormat` codec, `Atproto.DNSTXTFetcher` protocol, and `Atproto.DoHTXTFetcher` conformer they're built on. Together these implement the two handle-resolution methods from the atproto handle spec (https://atproto.com/specs/handle) - the DNS TXT method and the HTTPS well-known method - as standalone, portable, `Sendable` components matching `DidWebResolver`/`DidPlcResolver`'s conventions: redirect refusal, response-size bounds, and the resolved value parsed as an `Atproto.DID` (method form checked via `Atproto.DID.init(string:)`).
89 changes: 89 additions & 0 deletions Sources/AtprotoClient/Agent/Agents/BskyAppAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,33 @@ extension Atproto.XRPC.BskyAppCallable {
parameters: .init(actor: actor)
)
}

/// `getProfile` declares no dedicated not-found error - a missing actor
/// arrives as a 400 `InvalidRequest` whose message happens to be "Profile
/// not found". Maps exactly that shape to `nil`; every other error
/// (including other `InvalidRequest` messages, and a deactivated or
/// suspended account's `AccountDeactivated`/`AccountTakedown`) rethrows.
public func bskyProfileIfExists(
actor: LexiconString.AtIdentifier
) async throws -> Lexicon.App.Bsky.Actor.Defs.ProfileViewDetailed? {
do {
return try await bskyProfile(actor: actor)
} catch Atproto.XRPC.ParseError.xrpcError(
status: .badRequest,
error: let error
)
where error.error == "InvalidRequest"
&& error.message == "Profile not found"
{
return nil
}
}
}

extension Atproto.XRPC.BskyAppCallable {
/// Drops every not-found subject and loses which ones they were - use
/// ``relationshipLookup(actor:others:)`` to keep that information. Still
/// throws `GetRelationships.Errors.tooManyOthersInput` above 30 subjects.
public func getRelationships(
actor: Atproto.DID,
subjects: [Atproto.DID]
Expand All @@ -79,6 +103,71 @@ extension Atproto.XRPC.BskyAppCallable {
return results.relationships
.compactMap { $0.asRelationships }
}

/// `others` deduped (preserving order) and chunked into requests of at
/// most 30, the lexicon's `others.maxLength`, merging every chunk's
/// result. Only `.relationship`
/// entries for a requested DID count as found - an unrequested DID the
/// server threw in is dropped, and `.notFoundActor` is never used to
/// populate `found` (its `actor` can arrive in handle form, which can't
/// be mapped back to the DID that was actually requested). Whatever
/// wasn't found - whether the server said so explicitly (by DID or by
/// handle) or simply omitted it - lands in `notFound`. See
/// `GetRelationships.Lookup`'s doc for why this isn't an
/// account-existence check.
public func relationshipLookup(
actor: Atproto.DID,
others: [Atproto.DID]
) async throws -> Lexicon.App.Bsky.Graph.GetRelationships.Lookup {
let deduped = Self.dedupedPreservingOrder(others)
guard !deduped.isEmpty else {
return .init()
}
let requested = Set(deduped)

var found: [Atproto.DID: Lexicon.App.Bsky.Graph.Relationships] = [:]
for chunk in Self.chunked(
deduped,
intoSizeAtMost: Lexicon.App.Bsky.Graph.GetRelationships.Parameters.maxOthers
) {
let parameters = try Lexicon.App.Bsky.Graph.GetRelationships.Parameters(
actor: .did(actor),
others: chunk.map { .did($0) }
)
let output = try await call(
Lexicon.App.Bsky.Graph.GetRelationships.self,
parameters: parameters
)
guard output.actor == actor else {
throw Lexicon.App.Bsky.Graph.GetRelationships.Errors.actorMismatch(
requested: actor,
returned: output.actor
)
}
for entry in output.relationships {
guard case .relationship(let relationship) = entry,
requested.contains(relationship.did)
else { continue }
found[relationship.did] = relationship
}
}
let notFound = deduped.filter { found[$0] == nil }
return .init(found: found, notFound: notFound)
}

private static func dedupedPreservingOrder(_ dids: [Atproto.DID]) -> [Atproto.DID] {
var seen = Set<Atproto.DID>()
return dids.filter { seen.insert($0).inserted }
}

private static func chunked(
_ dids: [Atproto.DID],
intoSizeAtMost size: Int
) -> [[Atproto.DID]] {
stride(from: 0, to: dids.count, by: size).map {
Array(dids[$0..<Swift.min($0 + size, dids.count)])
}
}
}

public enum BskyAppViewPublicSocialGraphs: Sendable {
Expand Down
111 changes: 111 additions & 0 deletions Sources/AtprotoClient/BskyCDN.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//
// BskyCDN.swift
// AtprotoClient
//

import AtprotoTypes
import Foundation

extension Atproto {
/// Builds Bluesky CDN URLs for a blob reference - a record's blob is a
/// content reference, not a URL.
public enum BskyCDN {
public static let defaultHost = URL(string: "https://cdn.bsky.app")!

public enum ImageKind: String, Sendable {
case avatar
case banner
}

/// - Parameter host: Must be a bare `http`/`https` origin (no path,
/// query, fragment, or userinfo) - throws otherwise. `@jpeg` is a
/// fixed CDN transcode target, not the blob's actual MIME type.
public static func imageURL(
_ kind: ImageKind,
did: Atproto.DID,
blob: Atproto.Primitive.Blob,
host: URL = defaultHost
) throws -> URL {
guard
let components = URLComponents(
url: host, resolvingAgainstBaseURL: false),
let scheme = components.scheme?.lowercased(),
scheme == "https" || scheme == "http",
let componentHost = components.host, !componentHost.isEmpty,
components.path.isEmpty || components.path == "/",
components.query == nil,
components.fragment == nil,
components.user == nil,
components.password == nil
else {
throw Errors.invalidHost
}
var built = components
// Normalize the scheme's case so "HTTPS://cdn.bsky.app" and
// "https://cdn.bsky.app/" both produce the same canonical URL as
// "https://cdn.bsky.app".
built.scheme = scheme
// `did.rawValue` is attacker-influenced (a DID's identifier is
// otherwise unvalidated) and only percent-encoding "/" out of it
// stops it from smuggling extra path segments (e.g.
// "did:plc:../../evil") into the CDN request - `URLComponents.path`
// leaves "/" alone since it's a legal path character, so this is
// set through `percentEncodedPath` instead, on an already-escaped
// string.
built.percentEncodedPath =
"/img/\(kind.rawValue)/plain/\(try Self.pathSegment(did.rawValue))/\(try Self.pathSegment(blob.ref.link.string))@jpeg"
guard let url = built.url else {
throw Errors.invalidHost
}
return url
}

// Built explicitly from RFC 3986 (unreserved + sub-delims + ":",
// excluding "/" and "@") rather than derived from
// `CharacterSet.urlPathAllowed` - Darwin's built-in set and a
// modified copy of it don't necessarily encode `:` the same way,
// which would make exact-URL tests diverge on Linux. Excluding "@"
// keeps the only literal "@" in the path the fixed "@jpeg" suffix.
private static let pathSegmentAllowed: CharacterSet = {
var allowed = CharacterSet()
allowed.insert(
charactersIn:
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
allowed.insert(charactersIn: "!$&'()*+,;=")
allowed.insert(charactersIn: ":")
return allowed
}()

private static func pathSegment(_ value: String) throws -> String {
guard
let encoded = value.addingPercentEncoding(
withAllowedCharacters: Self.pathSegmentAllowed)
else {
throw Errors.unencodablePathSegment
}
return encoded
}
}
}

extension Atproto.BskyCDN {
public enum Errors: Error, Equatable, Sendable {
/// `host` isn't a bare `http`/`https` origin (no path, query,
/// fragment, or userinfo).
case invalidHost
/// A DID or CID couldn't be percent-encoded into a path segment.
case unencodablePathSegment
}
}

extension Atproto.BskyCDN.Errors: LocalizedError {
public var errorDescription: String? {
switch self {
case .invalidHost:
"host must be a bare http(s) origin (no path, query, fragment, or userinfo)"
case .unencodablePathSegment:
"a DID or CID could not be percent-encoded into a CDN URL path segment"
}
}
}
51 changes: 51 additions & 0 deletions Sources/AtprotoClient/DNS/DNSTXTFetcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// DNSTXTFetcher.swift
// AtprotoClient
//

import AtprotoTypes
import Foundation

extension Atproto {
/// Resolves TXT records for a fully-qualified name.
///
/// Mirrors `HTTPFetcher`'s shape: a narrow protocol so the caller can inject a
/// platform conformer, a mock, or a platform's own system resolver.
public protocol DNSTXTFetcher: Sendable {
/// The strings of every TXT record found for `name`, one entry per record.
/// A record's own length-prefixed character-strings are already joined.
/// Empty when the name resolves but carries no TXT record (NXDOMAIN or NODATA).
func txtRecords(name: String) async throws -> [String]
}

public enum DNSTXTFetcherError: LocalizedError, Equatable, Sendable {
/// The name does not exist.
case nameError
/// The response's RCODE was neither success (0) nor NXDOMAIN (3).
case serverError(rcode: UInt8)
/// Ran past the caller's `timeout` without an answer.
case timedOut
/// Malformed DNS wire-format response - see `DNSWireFormat.DecodeError`.
case malformedResponse(DNSWireFormat.DecodeError)
/// A platform system resolver reported failure with its own error code -
/// not a DNS RCODE, so kept distinct from `serverError`. Unused by any
/// conformer in this package today; kept so a platform-specific conformer
/// added later doesn't need a breaking change to this enum.
case resolverFailure(code: Int32)
/// `DoHTXTFetcher` was constructed with no providers to try - a caller
/// misconfiguration, not a network failure, so kept distinct from `timedOut`.
case noProvidersConfigured

public var errorDescription: String? {
switch self {
case .nameError: "DNS name does not exist"
case .serverError(let rcode): "DNS server returned RCODE \(rcode)"
case .timedOut: "DNS query timed out"
case .malformedResponse(let error): "Malformed DNS response: \(error)"
case .resolverFailure(let code):
"Platform resolver failed with code \(code)"
case .noProvidersConfigured: "No DoH providers configured"
}
}
}
}
Loading
Loading