Bluesky feed server - NSFW Likes

feat: Store auth tokens on disk

Changed files
+90 -9
darkfeed
src
+14 -1
darkfeed/src/main/kotlin/Main.kt
··· 17 17 import rs.averyrive.darkfeed.server.FeedServer 18 18 import java.time.Instant 19 19 import java.time.format.DateTimeFormatter 20 + import kotlin.io.path.Path 20 21 21 22 val PACKAGE_NAME: String = object {}.javaClass.packageName 22 23 ··· 56 57 by option(help = "Description to use for the feed", envvar = "FEED_DESCRIPTION") 57 58 .default("Development version of the NSFW Likes feed. Likely will not work.") 58 59 60 + private val storageDir: String 61 + by option(help = "Directory to store data in", envvar = "STORAGE_DIR") 62 + .default("/var/lib/darkfeed") 63 + 59 64 private val debug: Boolean 60 65 by option(help = "Log debug information") 61 66 .flag(default = false) ··· 99 104 log.warn("Authorized requests (e.g. updating feed generator record) will not be available") 100 105 } 101 106 107 + // Create auth manager if the owner's account was found. 108 + val authManager = authAccount?.let { 109 + AuthManager( 110 + authAccount = authAccount, 111 + tokensPath = Path(storageDir, "auth_tokens.json") 112 + ) 113 + } 114 + 102 115 // Create the client used for Bluesky API calls. 103 116 val bskyApi = BskyApi( 104 - authManager = authAccount?.let { AuthManager(it) }, 117 + authManager = authManager, 105 118 atpIdentityClient = atpIdentityClient, 106 119 ) 107 120
+74 -7
darkfeed/src/main/kotlin/api/AuthManager.kt
··· 9 9 import io.ktor.http.* 10 10 import io.ktor.serialization.kotlinx.json.* 11 11 import kotlinx.serialization.Serializable 12 + import kotlinx.serialization.SerializationException 13 + import kotlinx.serialization.encodeToString 12 14 import kotlinx.serialization.json.Json 13 15 import org.slf4j.Logger 14 16 import org.slf4j.LoggerFactory 17 + import java.nio.file.Path 15 18 16 19 /** 17 20 * Stores auth tokens and handles all auth related requests. 18 21 * 19 22 * @param authAccount ATProto account to use for authorization. 20 23 * @param httpClient Client to use for requests. 24 + * @param tokensPath Path to store tokens in. 21 25 */ 22 26 class AuthManager( 23 27 private val authAccount: AuthAccount, ··· 31 35 32 36 install(Logging) 33 37 }, 38 + private val tokensPath: Path, 34 39 ) { 40 + /** Logger to use for this class. */ 41 + private val log: Logger = LoggerFactory.getLogger(this.javaClass) 42 + 43 + init { 44 + loadTokens() 45 + } 46 + 35 47 /** The current bearer tokens. */ 36 48 var authTokens: AuthTokens? = null 37 49 private set ··· 40 52 val authAccountDid 41 53 get() = authAccount.username 42 54 43 - /** Logger to use for this class. */ 44 - private val log: Logger = LoggerFactory.getLogger(this.javaClass) 45 55 46 56 /** Create a new auth session. */ 47 57 suspend fun createSession() { ··· 80 90 val authSession: AuthSession = response.body() 81 91 this.authTokens = authSession.into() 82 92 log.debug("New session created, updated auth tokens.") 93 + this.saveTokens() 83 94 } catch (error: Exception) { 84 95 TODO("Handle deserialization errors") 85 96 } ··· 94 105 /** Refresh the current auth session. */ 95 106 suspend fun refreshSession() { 96 107 val refreshSessionUrl = buildUrl { 97 - protocol = URLProtocol.HTTPS 98 - host = this@AuthManager.authAccount.pdsHost 99 - path("/xrpc/com.atproto.server.refreshSession") 108 + takeFrom(this@AuthManager.authAccount.pdsHost) 109 + appendPathSegments("xrpc", "com.atproto.server.refreshSession") 100 110 } 101 111 102 112 val refreshToken = this.authTokens?.refreshToken!! ··· 116 126 val authSession: AuthSession = response.body() 117 127 this.authTokens = authSession.into() 118 128 log.debug("Session refreshed, updated auth tokens") 129 + this.saveTokens() 119 130 } catch (error: Exception) { 120 131 TODO("Handle deserialization errors") 121 132 } ··· 126 137 } 127 138 } 128 139 } 140 + 141 + /** 142 + * Load tokens from disk. 143 + */ 144 + private fun loadTokens() { 145 + log.debug("Loading auth tokens from '{}'...", tokensPath) 146 + 147 + val tokensFile = tokensPath.toFile() 148 + 149 + // Do nothing if tokens file doesn't exist. 150 + if (!tokensFile.isFile) { 151 + log.debug("No tokens found at '{}'", tokensPath) 152 + return 153 + } 154 + 155 + // Load auth tokens from file. 156 + val authTokens = try { 157 + Json.decodeFromString<AuthTokens>(tokensFile.readText()) 158 + } catch (error: SerializationException) { 159 + log.warn("Failed to load auth tokens from '{}': {}", tokensPath, error.message) 160 + return 161 + } 162 + 163 + // Don't use tokens if they are for a different account. 164 + if (this.authAccountDid != authTokens.did) { 165 + log.info("Refusing to use stored auth tokens for '{}'", authTokens.did) 166 + return 167 + } 168 + 169 + // Update auth tokens. 170 + this.authTokens = authTokens 171 + 172 + log.debug("Loaded tokens") 173 + } 174 + 175 + /** 176 + * Save tokens to disk. 177 + */ 178 + private fun saveTokens() { 179 + log.debug("Saving auth tokens to '{}'...", tokensPath) 180 + 181 + // Open file and create parent directory if necessary. 182 + val tokensFile = tokensPath.toFile() 183 + tokensFile.parentFile?.mkdirs() 184 + 185 + // Serialize auth tokens and write to the tokens file. 186 + val tokensData = Json.encodeToString(authTokens) 187 + tokensFile.bufferedWriter().use { writer -> 188 + writer.write(tokensData) 189 + } 190 + 191 + log.debug("Saved tokens") 192 + } 129 193 } 130 194 131 195 /** ··· 147 211 * @param accessToken Token used for normal authorized requests. 148 212 * @param refreshToken Token used for session refresh requests. 149 213 */ 214 + @Serializable 150 215 data class AuthTokens( 151 216 val accessToken: String, 152 217 val refreshToken: String, 218 + val did: String, 153 219 ) 154 220 155 221 /** ··· 161 227 @Serializable 162 228 data class AuthSession( 163 229 val accessJwt: String, 164 - val refreshJwt: String 230 + val refreshJwt: String, 231 + val did: String, 165 232 ) { 166 233 /** Create `AuthTokens` from an `AuthSession`. */ 167 - fun into(): AuthTokens = AuthTokens(this.accessJwt, this.refreshJwt) 234 + fun into(): AuthTokens = AuthTokens(this.accessJwt, this.refreshJwt, this.did) 168 235 }
+2 -1
darkfeed/src/main/kotlin/api/AuthPlugin.kt
··· 10 10 import org.slf4j.LoggerFactory 11 11 12 12 const val PLUGIN_NAME: String = "AuthPlugin" 13 + val LOGGER_NAME: String = "${object {}.javaClass.packageName}.$PLUGIN_NAME" 13 14 14 15 val AuthPlugin = createClientPlugin(PLUGIN_NAME, ::AuthPluginConfig) { 15 16 val authManager = pluginConfig.authManager ?: throw AuthPluginConfigurationError("Auth manager is required") 16 17 val authMutex = Mutex() 17 - val log = LoggerFactory.getLogger(PLUGIN_NAME) 18 + val log = LoggerFactory.getLogger(LOGGER_NAME) 18 19 19 20 // Add authorization header to requests. 20 21 onRequest { request, _ ->