Bluesky feed server - NSFW Likes

feat: Add support for non-Bluesky PDSes

Fixes #2

+487 -37
+34 -16
darkfeed/src/main/kotlin/Main.kt
··· 10 10 import com.github.ajalt.clikt.parameters.options.required 11 11 import com.github.ajalt.clikt.parameters.types.int 12 12 import org.slf4j.LoggerFactory 13 - import rs.averyrive.darkfeed.api.AuthAccount 14 - import rs.averyrive.darkfeed.api.AuthManager 15 - import rs.averyrive.darkfeed.api.BskyApi 13 + import rs.averyrive.darkfeed.api.* 16 14 import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.Generator 15 + import rs.averyrive.darkfeed.api.model.DidException 16 + import rs.averyrive.darkfeed.api.model.toDid 17 17 import rs.averyrive.darkfeed.server.FeedServer 18 18 import java.time.Instant 19 19 import java.time.format.DateTimeFormatter ··· 23 23 suspend fun main(args: Array<String>) = DarkFeedApp().main(args) 24 24 25 25 class DarkFeedApp : SuspendingCliktCommand(name = "darkfeed") { 26 - private val ownerPdsHost: String 27 - by option(help = "Feed owner's PDS", envvar = "OWNER_PDS_HOST") 28 - .required() 29 - 30 26 private val ownerDid: String 31 27 by option(help = "Feed owner's DID", envvar = "OWNER_DID") 32 28 .required() ··· 76 72 logger.debug("Debug logs enabled") 77 73 } 78 74 75 + val atpIdentityClient = AtpIdentityClient() 76 + 77 + // Try to create auth account by looking up the owner's PDS. 78 + val authAccount: AuthAccount? = try { 79 + AuthAccount( 80 + username = ownerDid, 81 + password = ownerPassword, 82 + pdsHost = atpIdentityClient.resolvePdsFromDid(ownerDid.toDid()) 83 + ) 84 + } catch (error: DidNotFoundException) { 85 + log.warn("Failed to look up owner's DID: {}", error.message) 86 + null 87 + } catch (error: PdsNotFoundException) { 88 + log.warn("Failed to find owner's PDS: {}", error.message) 89 + null 90 + } catch (error: DidException) { 91 + log.warn("Failed to parse owner's DID: {}", error.message) 92 + null 93 + } catch (error: Exception) { 94 + log.warn("Unknown failure occurred: {}", error.message) 95 + null 96 + } finally { 97 + log.warn("Authorized requests (e.g. updating feed generator record) will not be available") 98 + } 99 + 79 100 // Create the client used for Bluesky API calls. 80 101 val bskyApi = BskyApi( 81 - authManager = AuthManager( 82 - authAccount = AuthAccount( 83 - username = ownerDid, 84 - password = ownerPassword, 85 - pdsHost = ownerPdsHost, 86 - ) 87 - ) 102 + authManager = authAccount?.let { AuthManager(it) }, 103 + atpIdentityClient = atpIdentityClient, 88 104 ) 89 105 90 106 // Update the feed generator record if necessary. 91 107 try { 92 108 verifyAndUpdateFeedGeneratorRecord(bskyApi) 93 109 } catch (error: Exception) { 94 - log.warn("Failed to update feed generator record: {}", error.toString()) 110 + log.warn("Failed to update feed generator record: {}", error.message) 95 111 } 96 112 97 113 // Serve the feed generator. ··· 123 139 return 124 140 } 125 141 126 - log.info("Updating feed generator record '{}' for '{}' on '{}'", feedRecordKey, ownerDid, ownerPdsHost) 142 + log.info("Updating feed generator record '{}' for '{}'", feedRecordKey, ownerDid) 127 143 128 144 // Put the new feed generator record. 129 145 api.putFeedGeneratorRecord(ownerDid, feedRecordKey, feedGeneratorRecord) 146 + 147 + log.info("Updated feed generator record '{}'", feedRecordKey) 130 148 } 131 149 }
+234
darkfeed/src/main/kotlin/api/AtpIdentityClient.kt
··· 1 + package rs.averyrive.darkfeed.api 2 + 3 + import io.ktor.client.* 4 + import io.ktor.client.call.* 5 + import io.ktor.client.engine.cio.* 6 + import io.ktor.client.plugins.contentnegotiation.* 7 + import io.ktor.client.plugins.logging.* 8 + import io.ktor.client.request.* 9 + import io.ktor.client.statement.* 10 + import io.ktor.http.* 11 + import io.ktor.serialization.kotlinx.json.* 12 + import kotlinx.serialization.Serializable 13 + import kotlinx.serialization.SerializationException 14 + import kotlinx.serialization.json.Json 15 + import org.slf4j.LoggerFactory 16 + import rs.averyrive.darkfeed.api.model.* 17 + 18 + /** Client that handles identity related requests for ATProto accounts. */ 19 + class AtpIdentityClient { 20 + /** Logger to use for logging */ 21 + private val log = LoggerFactory.getLogger(this.javaClass) 22 + 23 + /** HTTP client to use for requests. */ 24 + private val httpClient = HttpClient(CIO) { 25 + install(ContentNegotiation) { 26 + json(Json { 27 + explicitNulls = false 28 + ignoreUnknownKeys = true 29 + }) 30 + } 31 + 32 + install(Logging) 33 + } 34 + 35 + /** 36 + * Resolve a handle's DID. 37 + * 38 + * @param handle Handle to resolve. 39 + * @return The handle's DID. 40 + * 41 + * @throws JsonParsingException Invalid body received. 42 + * @throws InvalidDidException Invalid DID receieved. 43 + * @throws UnsupportedDidMethodException Received DID uses an unsupported method. 44 + * @throws HandleNotFoundException Handle doesn't exist. 45 + * @throws RuntimeException An unexpected error ocurred. 46 + */ 47 + suspend fun resolveDidFromHandle(handle: String): Did { 48 + log.debug("Resolving DID for '{}'...", handle) 49 + 50 + @Serializable 51 + data class ResolveHandleResponse(val did: String) 52 + 53 + // Resolve handle using public Bluesky API endpoint. 54 + val resolveHandleResponse = httpClient.get { 55 + url { 56 + protocol = URLProtocol.HTTPS 57 + host = PUBLIC_BSKY_API_HOST 58 + appendPathSegments("xrpc", "com.atproto.identity.resolveHandle") 59 + } 60 + parameter("handle", handle) 61 + } 62 + 63 + // Get the DID from the response. 64 + val did = when (resolveHandleResponse.status) { 65 + HttpStatusCode.OK -> { 66 + // Try to get the DID from the response. 67 + val didString = try { 68 + resolveHandleResponse.body<ResolveHandleResponse>().did 69 + } catch (error: SerializationException) { 70 + throw JsonParsingException( 71 + message = "Failed to deserialize response", 72 + cause = error, 73 + body = resolveHandleResponse.bodyAsText() 74 + ) 75 + } 76 + 77 + // Try to parse the DID. 78 + didString.toDid() 79 + } 80 + 81 + HttpStatusCode.BadRequest -> { 82 + // Try to read the error. 83 + val errorResponse = try { 84 + resolveHandleResponse.body<AtpErrorResponse>() 85 + } catch (error: SerializationException) { 86 + throw JsonParsingException( 87 + message = "Failed to deserialize error response", 88 + cause = error, 89 + body = resolveHandleResponse.bodyAsText() 90 + ) 91 + } 92 + 93 + when (errorResponse.atpError) { 94 + AtpError.HandleNotFound -> throw HandleNotFoundException(handle) 95 + // Other errors shouldn't occur unless there's a bug somewhere. 96 + else -> throw RuntimeException("Invalid response receieved: ${resolveHandleResponse.bodyAsText()}") 97 + } 98 + } 99 + 100 + // Other errors shouldn't occur unless there's a bug somewhere. 101 + else -> throw RuntimeException("Received unexpected response: ${resolveHandleResponse.bodyAsText()}") 102 + } 103 + 104 + log.debug("Resolved DID for '{}': {}", handle, did.toString()) 105 + 106 + return did 107 + } 108 + 109 + /** 110 + * Resolve a DID's PDS host. 111 + * 112 + * @param did DID to resolve. 113 + * @return PDS hostname. 114 + * 115 + * @throws JsonParsingException Invalid body received. 116 + * @throws DidNotFoundException DID does not exist, is not accessible, or 117 + * has been taken down. 118 + * @throws PdsNotFoundException DID does not specify a PDS. 119 + * @throws RuntimeException An nexpected error ocurred. 120 + */ 121 + suspend fun resolvePdsFromDid(did: Did): String { 122 + log.debug("Resolving PDS for '{}'...", did.toString()) 123 + 124 + // Get the DID document, using either `plc.directory` or 125 + // `/.well-known/did.json` depending on the type of DID. 126 + val getDidDocResponse = when (did) { 127 + is Did.Plc -> httpClient.get { 128 + url { 129 + takeFrom("https://plc.directory") 130 + appendPathSegments(did.toString()) 131 + } 132 + } 133 + 134 + is Did.Web -> httpClient.get { 135 + url { 136 + protocol = URLProtocol.HTTPS 137 + host = did.identifier 138 + appendPathSegments(".well-known", "did.json") 139 + } 140 + } 141 + } 142 + 143 + // Parse the DID document. 144 + val didDoc = when (getDidDocResponse.status) { 145 + HttpStatusCode.OK -> try { 146 + getDidDocResponse.body<DidDoc>() 147 + } catch (error: SerializationException) { 148 + throw JsonParsingException( 149 + message = "Failed to parse DID doc", 150 + cause = error, 151 + body = getDidDocResponse.bodyAsText() 152 + ) 153 + } 154 + 155 + // DID doesn't exist or has been taken down. 156 + HttpStatusCode.NotFound, HttpStatusCode.Gone -> throw DidNotFoundException(did) 157 + 158 + // PLC `ResolveDid` should not return any other response codes. 159 + else -> throw RuntimeException("Received unexpected response: ${getDidDocResponse.bodyAsText()}") 160 + } 161 + 162 + // Find the PDS service and get the hostname. 163 + val pdsHost = didDoc 164 + .service 165 + .find { service -> service.id == "#atproto_pds" } 166 + ?.serviceEndpoint 167 + ?: throw PdsNotFoundException(did) 168 + 169 + log.debug("Resolved PDS for '{}': {}", did.toString(), pdsHost) 170 + 171 + return pdsHost 172 + } 173 + } 174 + 175 + /** 176 + * Base class for errors specific to the [AtpIdentityClient]. 177 + * 178 + * @param message Description of the error. 179 + * @param cause Underlying exception that caused the error, if any. 180 + */ 181 + open class AtpIdentityClientException( 182 + message: String, 183 + cause: Throwable? = null, 184 + ) : RuntimeException(message, cause) 185 + 186 + /** 187 + * Failed to parse a JSON response from an API call. 188 + * Indicates an unexpected or malformed response. 189 + * 190 + * @param message Description of the error. 191 + * @param cause Original [SerializationException] that occurred. 192 + * @param body Raw body that failed parsing, if available. 193 + */ 194 + class JsonParsingException( 195 + message: String, 196 + cause: SerializationException, 197 + val body: String? = null, 198 + ) : AtpIdentityClientException( 199 + message = "Failed to parse JSON object: $message. Body: $body", 200 + cause = cause 201 + ) 202 + 203 + /** 204 + * Requested handle doesn't exist. 205 + * 206 + * @param handle Requested handle. 207 + */ 208 + class HandleNotFoundException( 209 + val handle: String, 210 + ) : AtpIdentityClientException( 211 + message = "Handle not found: $handle" 212 + ) 213 + 214 + /** 215 + * Requested DID does not exist or has been taken down. 216 + * 217 + * @param did Requested DID. 218 + */ 219 + class DidNotFoundException( 220 + val did: Did, 221 + ) : AtpIdentityClientException( 222 + message = "DID not found: $did" 223 + ) 224 + 225 + /** 226 + * Requested DID does not have an associated PDS. 227 + * 228 + * @param did Requested DID. 229 + */ 230 + class PdsNotFoundException( 231 + val did: Did, 232 + ) : AtpIdentityClientException( 233 + message = "PDS not found for DID: $did" 234 + )
+6 -3
darkfeed/src/main/kotlin/api/AuthManager.kt
··· 36 36 var authTokens: AuthTokens? = null 37 37 private set 38 38 39 + /** The handle of the account used for authentication. */ 40 + val authAccountDid 41 + get() = authAccount.username 42 + 39 43 /** Logger to use for this class. */ 40 44 private val log: Logger = LoggerFactory.getLogger(this.javaClass) 41 45 ··· 45 49 data class Request(val identifier: String, val password: String) 46 50 47 51 val createSessionUrl = buildUrl { 48 - protocol = URLProtocol.HTTPS 49 - host = this@AuthManager.authAccount.pdsHost 50 - path("/xrpc/com.atproto.server.createSession") 52 + takeFrom(this@AuthManager.authAccount.pdsHost) 53 + appendPathSegments("xrpc", "com.atproto.server.createSession") 51 54 } 52 55 53 56 val requestBody = Request(
+1 -1
darkfeed/src/main/kotlin/api/AuthPlugin.kt
··· 154 154 155 155 open class AuthPluginError(message: String, cause: Throwable? = null) : RuntimeException(message, cause) 156 156 157 - class AuthPluginConfigurationError(message: String) : AuthPluginError(message) 157 + class AuthPluginConfigurationError(message: String) : AuthPluginError(message)
+48 -16
darkfeed/src/main/kotlin/api/BskyApi.kt
··· 3 3 import io.ktor.client.* 4 4 import io.ktor.client.call.* 5 5 import io.ktor.client.engine.cio.* 6 - import io.ktor.client.plugins.* 7 6 import io.ktor.client.plugins.contentnegotiation.* 8 7 import io.ktor.client.plugins.logging.* 9 8 import io.ktor.client.request.* ··· 17 16 import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.Generator 18 17 import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.LikeRef 19 18 import rs.averyrive.darkfeed.api.lexicon.app.bsky.feed.defs.PostView 19 + import rs.averyrive.darkfeed.api.model.toDid 20 + 21 + const val PUBLIC_BSKY_API_HOST: String = "public.api.bsky.app" 20 22 21 23 class BskyApi( 22 - private val authManager: AuthManager, 23 - private val pdsUrl: Url = Url("https://bsky.social"), 24 + private val authManager: AuthManager? = null, 25 + private val atpIdentityClient: AtpIdentityClient, 24 26 ) { 25 27 private val httpClient = HttpClient(CIO) { 26 28 install(ContentNegotiation) { ··· 32 34 33 35 install(Logging) 34 36 35 - install(AuthPlugin) { 36 - this.authManager = this@BskyApi.authManager 37 - } 38 - 39 - defaultRequest { 40 - url { 41 - protocol = pdsUrl.protocol 42 - host = pdsUrl.host 43 - path("xrpc/") 37 + if (this@BskyApi.authManager != null) { 38 + install(AuthPlugin) { 39 + authManager = this@BskyApi.authManager 44 40 } 45 41 } 46 42 } ··· 57 53 @Serializable 58 54 data class Response(val uri: String, val value: Generator) 59 55 60 - val response = httpClient.get("com.atproto.repo.getRecord") { 56 + val response = httpClient.get { 57 + url { 58 + protocol = URLProtocol.HTTPS 59 + host = PUBLIC_BSKY_API_HOST 60 + path("/xrpc/com.atproto.repo.getRecord") 61 + } 61 62 parameter("repo", repo) 62 63 parameter("collection", "app.bsky.feed.generator") 63 64 parameter("rkey", rkey) ··· 81 82 } 82 83 83 84 suspend fun putFeedGeneratorRecord(repo: String, rkey: String, record: Generator) { 85 + if (authManager == null) throw NoAuthManagerException("Authorization is required to put feed generator record") 86 + 84 87 @Serializable 85 88 data class Request( 86 89 val repo: String, ··· 89 92 val record: Generator, 90 93 ) 91 94 92 - val response = httpClient.post("com.atproto.repo.putRecord") { 95 + val ownerPdsHost = atpIdentityClient.resolvePdsFromDid(authManager.authAccountDid.toDid()) 96 + 97 + val response = httpClient.post { 98 + url { 99 + takeFrom(ownerPdsHost) 100 + appendPathSegments("xrpc", "com.atproto.repo.putRecord") 101 + } 102 + 93 103 contentType(ContentType.Application.Json) 94 104 setBody(Request(repo, "app.bsky.feed.generator", rkey, record)) 95 105 } 106 + 107 + log.debug("Response code: {}", response.status) 96 108 97 109 when (response.status) { 98 110 HttpStatusCode.BadRequest, ··· 104 116 @Serializable 105 117 data class Response(val cursor: String?, val records: List<LikeRef>) 106 118 107 - val response = httpClient.get("com.atproto.repo.listRecords") { 119 + val requestorPdsHost = atpIdentityClient.resolvePdsFromDid(actor.toDid()) 120 + 121 + val response = httpClient.get { 122 + url { 123 + takeFrom(requestorPdsHost) 124 + appendPathSegments("xrpc", "com.atproto.repo.listRecords") 125 + } 108 126 parameter("repo", actor) 109 127 parameter("collection", "app.bsky.feed.like") 110 128 parameter("limit", 100) ··· 132 150 @Serializable 133 151 data class Response(val posts: List<PostView>) 134 152 135 - val response = httpClient.get("app.bsky.feed.getPosts") { 153 + val response = httpClient.get { 154 + url { 155 + protocol = URLProtocol.HTTPS 156 + host = PUBLIC_BSKY_API_HOST 157 + path("/xrpc/app.bsky.feed.getPosts") 158 + } 136 159 posts.forEach { parameter("uris", it) } 137 160 } 138 161 ··· 149 172 } 150 173 } 151 174 } 175 + 176 + open class BskyApiException( 177 + message: String, 178 + cause: Throwable? = null, 179 + ) : RuntimeException(message, cause) 180 + 181 + open class NoAuthManagerException( 182 + message: String, 183 + ) : BskyApiException("No auth manager configured: $message")
+19
darkfeed/src/main/kotlin/api/model/AtpErrorResponse.kt
··· 1 + package rs.averyrive.darkfeed.api.model 2 + 3 + import kotlinx.serialization.Serializable 4 + 5 + @Serializable 6 + /** An ATProto error name. */ 7 + enum class AtpError { 8 + InvalidRequest, 9 + ExpiredToken, 10 + InvalidToken, 11 + HandleNotFound, 12 + } 13 + 14 + /** An ATProto error response. */ 15 + @Serializable 16 + data class AtpErrorResponse( 17 + val atpError: AtpError, 18 + val message: String, 19 + )
+128
darkfeed/src/main/kotlin/api/model/Did.kt
··· 1 + package rs.averyrive.darkfeed.api.model 2 + 3 + import kotlinx.serialization.KSerializer 4 + import kotlinx.serialization.Serializable 5 + import kotlinx.serialization.descriptors.PrimitiveKind 6 + import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor 7 + import kotlinx.serialization.descriptors.SerialDescriptor 8 + import kotlinx.serialization.encoding.Decoder 9 + import kotlinx.serialization.encoding.Encoder 10 + 11 + @Serializable(with = Did.Companion.Serializer::class) 12 + /** Decentralized identifier (DID) for an atproto account. */ 13 + sealed class Did { 14 + /** The type as used in a full identifier. */ 15 + abstract val method: String 16 + 17 + /** The method specific identifier. */ 18 + abstract val identifier: String 19 + 20 + /** 21 + * Public Ledger of Credentials (PLC) DID. 22 + * 23 + * @param id PLC identifier. 24 + */ 25 + data class Plc(private val id: String) : Did() { 26 + override val method: String get() = METHOD 27 + 28 + override val identifier: String get() = id 29 + 30 + override fun toString(): String = "did:$method:$id" 31 + 32 + companion object { 33 + /** The type as used in a full identifier. */ 34 + const val METHOD: String = "plc" 35 + } 36 + } 37 + 38 + /** 39 + * Domain name DID. 40 + * 41 + * @param host Hostname of the DID. 42 + */ 43 + data class Web(private val host: String) : Did() { 44 + override val method: String get() = METHOD 45 + 46 + override val identifier: String get() = host 47 + 48 + override fun toString(): String = "did:$method:$host" 49 + 50 + companion object { 51 + const val METHOD: String = "web" 52 + } 53 + } 54 + 55 + companion object { 56 + /** Simple regex used for validating DIDs. */ 57 + private val DID_REGEX: Regex = Regex("^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$") 58 + 59 + /** 60 + * Create a DID from a string representation. 61 + * 62 + * @param did String representation of a DID. 63 + * @return The DID. 64 + * 65 + * @throws InvalidDidException DID is invalid. 66 + * @throws UnsupportedDidMethodException DID method is not supported. 67 + */ 68 + fun fromString(did: String): Did { 69 + if (!DID_REGEX.matches(did)) { 70 + throw InvalidDidException(did) 71 + } 72 + 73 + val parts = did.split(':', limit = 3) 74 + val method = parts[1] 75 + val identifier = parts[2] 76 + 77 + return when (method) { 78 + Plc.METHOD -> Plc(identifier) 79 + Web.METHOD -> Web(identifier) 80 + else -> throw UnsupportedDidMethodException(did, method) 81 + } 82 + } 83 + 84 + /** Simple serializer for DIDs which uses `toString`/`toDid`. */ 85 + object Serializer : KSerializer<Did> { 86 + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Did", PrimitiveKind.STRING) 87 + 88 + override fun deserialize(decoder: Decoder): Did = decoder.decodeString().toDid() 89 + 90 + override fun serialize(encoder: Encoder, value: Did) = encoder.encodeString(value.toString()) 91 + } 92 + } 93 + } 94 + 95 + /** 96 + * Create a DID from a string representation. 97 + * 98 + * @return The DID. 99 + * 100 + * @throws InvalidDidException DID is invalid. 101 + * @throws UnsupportedDidMethodException DID method is not supported. 102 + */ 103 + fun String.toDid(): Did = Did.fromString(this) 104 + 105 + /** 106 + * Base class for errors specific to a [Did]. 107 + * 108 + * @param message Description of the error. 109 + * @param cause Underlying exception that cuased the error, if any. 110 + */ 111 + open class DidException( 112 + message: String, 113 + cause: Throwable? = null 114 + ) : RuntimeException(message, cause) 115 + 116 + /** 117 + * Given DID string is invalid. 118 + * 119 + * @param did Given DID string. 120 + */ 121 + class InvalidDidException( 122 + val did: String, 123 + ) : DidException(message = "Invalid DID: $did") 124 + 125 + class UnsupportedDidMethodException( 126 + val did: String, 127 + val method: String, 128 + ) : DidException(message = "Invalid method '$method' for DID: $did")
+16
darkfeed/src/main/kotlin/api/model/DidDoc.kt
··· 1 + package rs.averyrive.darkfeed.api.model 2 + 3 + import kotlinx.serialization.Serializable 4 + 5 + @Serializable 6 + data class DidDoc( 7 + val id: Did, 8 + val service: List<Service> 9 + ) { 10 + @Serializable 11 + data class Service( 12 + val id: String, 13 + val type: String, 14 + val serviceEndpoint: String, 15 + ) 16 + }
+1 -1
darkfeed/src/main/resources/logback.xml
··· 1 1 <configuration> 2 2 <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> 3 3 <encoder> 4 - <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> 4 + <pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} %.-1level %logger{0} %method - %msg%n</pattern> 5 5 </encoder> 6 6 </appender> 7 7 <root level="INFO">