···1010import com.github.ajalt.clikt.parameters.options.required
1111import com.github.ajalt.clikt.parameters.types.int
1212import org.slf4j.LoggerFactory
1313-import rs.averyrive.darkfeed.api.AuthAccount
1414-import rs.averyrive.darkfeed.api.AuthManager
1515-import rs.averyrive.darkfeed.api.BskyApi
1313+import rs.averyrive.darkfeed.api.*
1614import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.Generator
1515+import rs.averyrive.darkfeed.api.model.DidException
1616+import rs.averyrive.darkfeed.api.model.toDid
1717import rs.averyrive.darkfeed.server.FeedServer
1818import java.time.Instant
1919import java.time.format.DateTimeFormatter
···2323suspend fun main(args: Array<String>) = DarkFeedApp().main(args)
24242525class DarkFeedApp : SuspendingCliktCommand(name = "darkfeed") {
2626- private val ownerPdsHost: String
2727- by option(help = "Feed owner's PDS", envvar = "OWNER_PDS_HOST")
2828- .required()
2929-3026 private val ownerDid: String
3127 by option(help = "Feed owner's DID", envvar = "OWNER_DID")
3228 .required()
···7672 logger.debug("Debug logs enabled")
7773 }
78747575+ val atpIdentityClient = AtpIdentityClient()
7676+7777+ // Try to create auth account by looking up the owner's PDS.
7878+ val authAccount: AuthAccount? = try {
7979+ AuthAccount(
8080+ username = ownerDid,
8181+ password = ownerPassword,
8282+ pdsHost = atpIdentityClient.resolvePdsFromDid(ownerDid.toDid())
8383+ )
8484+ } catch (error: DidNotFoundException) {
8585+ log.warn("Failed to look up owner's DID: {}", error.message)
8686+ null
8787+ } catch (error: PdsNotFoundException) {
8888+ log.warn("Failed to find owner's PDS: {}", error.message)
8989+ null
9090+ } catch (error: DidException) {
9191+ log.warn("Failed to parse owner's DID: {}", error.message)
9292+ null
9393+ } catch (error: Exception) {
9494+ log.warn("Unknown failure occurred: {}", error.message)
9595+ null
9696+ } finally {
9797+ log.warn("Authorized requests (e.g. updating feed generator record) will not be available")
9898+ }
9999+79100 // Create the client used for Bluesky API calls.
80101 val bskyApi = BskyApi(
8181- authManager = AuthManager(
8282- authAccount = AuthAccount(
8383- username = ownerDid,
8484- password = ownerPassword,
8585- pdsHost = ownerPdsHost,
8686- )
8787- )
102102+ authManager = authAccount?.let { AuthManager(it) },
103103+ atpIdentityClient = atpIdentityClient,
88104 )
8910590106 // Update the feed generator record if necessary.
91107 try {
92108 verifyAndUpdateFeedGeneratorRecord(bskyApi)
93109 } catch (error: Exception) {
9494- log.warn("Failed to update feed generator record: {}", error.toString())
110110+ log.warn("Failed to update feed generator record: {}", error.message)
95111 }
9611297113 // Serve the feed generator.
···123139 return
124140 }
125141126126- log.info("Updating feed generator record '{}' for '{}' on '{}'", feedRecordKey, ownerDid, ownerPdsHost)
142142+ log.info("Updating feed generator record '{}' for '{}'", feedRecordKey, ownerDid)
127143128144 // Put the new feed generator record.
129145 api.putFeedGeneratorRecord(ownerDid, feedRecordKey, feedGeneratorRecord)
146146+147147+ log.info("Updated feed generator record '{}'", feedRecordKey)
130148 }
131149}
+234
darkfeed/src/main/kotlin/api/AtpIdentityClient.kt
···11+package rs.averyrive.darkfeed.api
22+33+import io.ktor.client.*
44+import io.ktor.client.call.*
55+import io.ktor.client.engine.cio.*
66+import io.ktor.client.plugins.contentnegotiation.*
77+import io.ktor.client.plugins.logging.*
88+import io.ktor.client.request.*
99+import io.ktor.client.statement.*
1010+import io.ktor.http.*
1111+import io.ktor.serialization.kotlinx.json.*
1212+import kotlinx.serialization.Serializable
1313+import kotlinx.serialization.SerializationException
1414+import kotlinx.serialization.json.Json
1515+import org.slf4j.LoggerFactory
1616+import rs.averyrive.darkfeed.api.model.*
1717+1818+/** Client that handles identity related requests for ATProto accounts. */
1919+class AtpIdentityClient {
2020+ /** Logger to use for logging */
2121+ private val log = LoggerFactory.getLogger(this.javaClass)
2222+2323+ /** HTTP client to use for requests. */
2424+ private val httpClient = HttpClient(CIO) {
2525+ install(ContentNegotiation) {
2626+ json(Json {
2727+ explicitNulls = false
2828+ ignoreUnknownKeys = true
2929+ })
3030+ }
3131+3232+ install(Logging)
3333+ }
3434+3535+ /**
3636+ * Resolve a handle's DID.
3737+ *
3838+ * @param handle Handle to resolve.
3939+ * @return The handle's DID.
4040+ *
4141+ * @throws JsonParsingException Invalid body received.
4242+ * @throws InvalidDidException Invalid DID receieved.
4343+ * @throws UnsupportedDidMethodException Received DID uses an unsupported method.
4444+ * @throws HandleNotFoundException Handle doesn't exist.
4545+ * @throws RuntimeException An unexpected error ocurred.
4646+ */
4747+ suspend fun resolveDidFromHandle(handle: String): Did {
4848+ log.debug("Resolving DID for '{}'...", handle)
4949+5050+ @Serializable
5151+ data class ResolveHandleResponse(val did: String)
5252+5353+ // Resolve handle using public Bluesky API endpoint.
5454+ val resolveHandleResponse = httpClient.get {
5555+ url {
5656+ protocol = URLProtocol.HTTPS
5757+ host = PUBLIC_BSKY_API_HOST
5858+ appendPathSegments("xrpc", "com.atproto.identity.resolveHandle")
5959+ }
6060+ parameter("handle", handle)
6161+ }
6262+6363+ // Get the DID from the response.
6464+ val did = when (resolveHandleResponse.status) {
6565+ HttpStatusCode.OK -> {
6666+ // Try to get the DID from the response.
6767+ val didString = try {
6868+ resolveHandleResponse.body<ResolveHandleResponse>().did
6969+ } catch (error: SerializationException) {
7070+ throw JsonParsingException(
7171+ message = "Failed to deserialize response",
7272+ cause = error,
7373+ body = resolveHandleResponse.bodyAsText()
7474+ )
7575+ }
7676+7777+ // Try to parse the DID.
7878+ didString.toDid()
7979+ }
8080+8181+ HttpStatusCode.BadRequest -> {
8282+ // Try to read the error.
8383+ val errorResponse = try {
8484+ resolveHandleResponse.body<AtpErrorResponse>()
8585+ } catch (error: SerializationException) {
8686+ throw JsonParsingException(
8787+ message = "Failed to deserialize error response",
8888+ cause = error,
8989+ body = resolveHandleResponse.bodyAsText()
9090+ )
9191+ }
9292+9393+ when (errorResponse.atpError) {
9494+ AtpError.HandleNotFound -> throw HandleNotFoundException(handle)
9595+ // Other errors shouldn't occur unless there's a bug somewhere.
9696+ else -> throw RuntimeException("Invalid response receieved: ${resolveHandleResponse.bodyAsText()}")
9797+ }
9898+ }
9999+100100+ // Other errors shouldn't occur unless there's a bug somewhere.
101101+ else -> throw RuntimeException("Received unexpected response: ${resolveHandleResponse.bodyAsText()}")
102102+ }
103103+104104+ log.debug("Resolved DID for '{}': {}", handle, did.toString())
105105+106106+ return did
107107+ }
108108+109109+ /**
110110+ * Resolve a DID's PDS host.
111111+ *
112112+ * @param did DID to resolve.
113113+ * @return PDS hostname.
114114+ *
115115+ * @throws JsonParsingException Invalid body received.
116116+ * @throws DidNotFoundException DID does not exist, is not accessible, or
117117+ * has been taken down.
118118+ * @throws PdsNotFoundException DID does not specify a PDS.
119119+ * @throws RuntimeException An nexpected error ocurred.
120120+ */
121121+ suspend fun resolvePdsFromDid(did: Did): String {
122122+ log.debug("Resolving PDS for '{}'...", did.toString())
123123+124124+ // Get the DID document, using either `plc.directory` or
125125+ // `/.well-known/did.json` depending on the type of DID.
126126+ val getDidDocResponse = when (did) {
127127+ is Did.Plc -> httpClient.get {
128128+ url {
129129+ takeFrom("https://plc.directory")
130130+ appendPathSegments(did.toString())
131131+ }
132132+ }
133133+134134+ is Did.Web -> httpClient.get {
135135+ url {
136136+ protocol = URLProtocol.HTTPS
137137+ host = did.identifier
138138+ appendPathSegments(".well-known", "did.json")
139139+ }
140140+ }
141141+ }
142142+143143+ // Parse the DID document.
144144+ val didDoc = when (getDidDocResponse.status) {
145145+ HttpStatusCode.OK -> try {
146146+ getDidDocResponse.body<DidDoc>()
147147+ } catch (error: SerializationException) {
148148+ throw JsonParsingException(
149149+ message = "Failed to parse DID doc",
150150+ cause = error,
151151+ body = getDidDocResponse.bodyAsText()
152152+ )
153153+ }
154154+155155+ // DID doesn't exist or has been taken down.
156156+ HttpStatusCode.NotFound, HttpStatusCode.Gone -> throw DidNotFoundException(did)
157157+158158+ // PLC `ResolveDid` should not return any other response codes.
159159+ else -> throw RuntimeException("Received unexpected response: ${getDidDocResponse.bodyAsText()}")
160160+ }
161161+162162+ // Find the PDS service and get the hostname.
163163+ val pdsHost = didDoc
164164+ .service
165165+ .find { service -> service.id == "#atproto_pds" }
166166+ ?.serviceEndpoint
167167+ ?: throw PdsNotFoundException(did)
168168+169169+ log.debug("Resolved PDS for '{}': {}", did.toString(), pdsHost)
170170+171171+ return pdsHost
172172+ }
173173+}
174174+175175+/**
176176+ * Base class for errors specific to the [AtpIdentityClient].
177177+ *
178178+ * @param message Description of the error.
179179+ * @param cause Underlying exception that caused the error, if any.
180180+ */
181181+open class AtpIdentityClientException(
182182+ message: String,
183183+ cause: Throwable? = null,
184184+) : RuntimeException(message, cause)
185185+186186+/**
187187+ * Failed to parse a JSON response from an API call.
188188+ * Indicates an unexpected or malformed response.
189189+ *
190190+ * @param message Description of the error.
191191+ * @param cause Original [SerializationException] that occurred.
192192+ * @param body Raw body that failed parsing, if available.
193193+ */
194194+class JsonParsingException(
195195+ message: String,
196196+ cause: SerializationException,
197197+ val body: String? = null,
198198+) : AtpIdentityClientException(
199199+ message = "Failed to parse JSON object: $message. Body: $body",
200200+ cause = cause
201201+)
202202+203203+/**
204204+ * Requested handle doesn't exist.
205205+ *
206206+ * @param handle Requested handle.
207207+ */
208208+class HandleNotFoundException(
209209+ val handle: String,
210210+) : AtpIdentityClientException(
211211+ message = "Handle not found: $handle"
212212+)
213213+214214+/**
215215+ * Requested DID does not exist or has been taken down.
216216+ *
217217+ * @param did Requested DID.
218218+ */
219219+class DidNotFoundException(
220220+ val did: Did,
221221+) : AtpIdentityClientException(
222222+ message = "DID not found: $did"
223223+)
224224+225225+/**
226226+ * Requested DID does not have an associated PDS.
227227+ *
228228+ * @param did Requested DID.
229229+ */
230230+class PdsNotFoundException(
231231+ val did: Did,
232232+) : AtpIdentityClientException(
233233+ message = "PDS not found for DID: $did"
234234+)
+6-3
darkfeed/src/main/kotlin/api/AuthManager.kt
···3636 var authTokens: AuthTokens? = null
3737 private set
38383939+ /** The handle of the account used for authentication. */
4040+ val authAccountDid
4141+ get() = authAccount.username
4242+3943 /** Logger to use for this class. */
4044 private val log: Logger = LoggerFactory.getLogger(this.javaClass)
4145···4549 data class Request(val identifier: String, val password: String)
46504751 val createSessionUrl = buildUrl {
4848- protocol = URLProtocol.HTTPS
4949- host = this@AuthManager.authAccount.pdsHost
5050- path("/xrpc/com.atproto.server.createSession")
5252+ takeFrom(this@AuthManager.authAccount.pdsHost)
5353+ appendPathSegments("xrpc", "com.atproto.server.createSession")
5154 }
52555356 val requestBody = Request(
···11+package rs.averyrive.darkfeed.api.model
22+33+import kotlinx.serialization.Serializable
44+55+@Serializable
66+/** An ATProto error name. */
77+enum class AtpError {
88+ InvalidRequest,
99+ ExpiredToken,
1010+ InvalidToken,
1111+ HandleNotFound,
1212+}
1313+1414+/** An ATProto error response. */
1515+@Serializable
1616+data class AtpErrorResponse(
1717+ val atpError: AtpError,
1818+ val message: String,
1919+)
+128
darkfeed/src/main/kotlin/api/model/Did.kt
···11+package rs.averyrive.darkfeed.api.model
22+33+import kotlinx.serialization.KSerializer
44+import kotlinx.serialization.Serializable
55+import kotlinx.serialization.descriptors.PrimitiveKind
66+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
77+import kotlinx.serialization.descriptors.SerialDescriptor
88+import kotlinx.serialization.encoding.Decoder
99+import kotlinx.serialization.encoding.Encoder
1010+1111+@Serializable(with = Did.Companion.Serializer::class)
1212+/** Decentralized identifier (DID) for an atproto account. */
1313+sealed class Did {
1414+ /** The type as used in a full identifier. */
1515+ abstract val method: String
1616+1717+ /** The method specific identifier. */
1818+ abstract val identifier: String
1919+2020+ /**
2121+ * Public Ledger of Credentials (PLC) DID.
2222+ *
2323+ * @param id PLC identifier.
2424+ */
2525+ data class Plc(private val id: String) : Did() {
2626+ override val method: String get() = METHOD
2727+2828+ override val identifier: String get() = id
2929+3030+ override fun toString(): String = "did:$method:$id"
3131+3232+ companion object {
3333+ /** The type as used in a full identifier. */
3434+ const val METHOD: String = "plc"
3535+ }
3636+ }
3737+3838+ /**
3939+ * Domain name DID.
4040+ *
4141+ * @param host Hostname of the DID.
4242+ */
4343+ data class Web(private val host: String) : Did() {
4444+ override val method: String get() = METHOD
4545+4646+ override val identifier: String get() = host
4747+4848+ override fun toString(): String = "did:$method:$host"
4949+5050+ companion object {
5151+ const val METHOD: String = "web"
5252+ }
5353+ }
5454+5555+ companion object {
5656+ /** Simple regex used for validating DIDs. */
5757+ private val DID_REGEX: Regex = Regex("^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$")
5858+5959+ /**
6060+ * Create a DID from a string representation.
6161+ *
6262+ * @param did String representation of a DID.
6363+ * @return The DID.
6464+ *
6565+ * @throws InvalidDidException DID is invalid.
6666+ * @throws UnsupportedDidMethodException DID method is not supported.
6767+ */
6868+ fun fromString(did: String): Did {
6969+ if (!DID_REGEX.matches(did)) {
7070+ throw InvalidDidException(did)
7171+ }
7272+7373+ val parts = did.split(':', limit = 3)
7474+ val method = parts[1]
7575+ val identifier = parts[2]
7676+7777+ return when (method) {
7878+ Plc.METHOD -> Plc(identifier)
7979+ Web.METHOD -> Web(identifier)
8080+ else -> throw UnsupportedDidMethodException(did, method)
8181+ }
8282+ }
8383+8484+ /** Simple serializer for DIDs which uses `toString`/`toDid`. */
8585+ object Serializer : KSerializer<Did> {
8686+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Did", PrimitiveKind.STRING)
8787+8888+ override fun deserialize(decoder: Decoder): Did = decoder.decodeString().toDid()
8989+9090+ override fun serialize(encoder: Encoder, value: Did) = encoder.encodeString(value.toString())
9191+ }
9292+ }
9393+}
9494+9595+/**
9696+ * Create a DID from a string representation.
9797+ *
9898+ * @return The DID.
9999+ *
100100+ * @throws InvalidDidException DID is invalid.
101101+ * @throws UnsupportedDidMethodException DID method is not supported.
102102+ */
103103+fun String.toDid(): Did = Did.fromString(this)
104104+105105+/**
106106+ * Base class for errors specific to a [Did].
107107+ *
108108+ * @param message Description of the error.
109109+ * @param cause Underlying exception that cuased the error, if any.
110110+ */
111111+open class DidException(
112112+ message: String,
113113+ cause: Throwable? = null
114114+) : RuntimeException(message, cause)
115115+116116+/**
117117+ * Given DID string is invalid.
118118+ *
119119+ * @param did Given DID string.
120120+ */
121121+class InvalidDidException(
122122+ val did: String,
123123+) : DidException(message = "Invalid DID: $did")
124124+125125+class UnsupportedDidMethodException(
126126+ val did: String,
127127+ val method: String,
128128+) : DidException(message = "Invalid method '$method' for DID: $did")
+16
darkfeed/src/main/kotlin/api/model/DidDoc.kt
···11+package rs.averyrive.darkfeed.api.model
22+33+import kotlinx.serialization.Serializable
44+55+@Serializable
66+data class DidDoc(
77+ val id: Did,
88+ val service: List<Service>
99+) {
1010+ @Serializable
1111+ data class Service(
1212+ val id: String,
1313+ val type: String,
1414+ val serviceEndpoint: String,
1515+ )
1616+}