mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-18 16:49:10 +02:00
Update auth to handle refresh tokens
This commit is contained in:
parent
67bab72783
commit
d8cdb7073e
13 changed files with 828 additions and 141 deletions
|
@ -0,0 +1,124 @@
|
||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
class SecureStorage(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SecureStorage"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val IV_LENGTH = 12
|
||||||
|
private const val TAG_LENGTH = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts and stores a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
|
||||||
|
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||||
|
val combined = cipher.iv + encryptedBytes
|
||||||
|
|
||||||
|
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
|
||||||
|
|
||||||
|
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and decrypts a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun getRefreshToken(serverConnectionId: String): String? {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
|
||||||
|
|
||||||
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
val iv = combined.copyOfRange(0, IV_LENGTH)
|
||||||
|
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
|
||||||
|
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
||||||
|
|
||||||
|
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||||
|
String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun removeRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
|
||||||
|
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a refresh token exists for a specific server connection
|
||||||
|
*/
|
||||||
|
fun hasRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
return sharedPrefs.contains("refresh_token_$serverConnectionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
return if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
} else {
|
||||||
|
createKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createKey(): SecretKey {
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||||
|
val keyGenSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenSpec)
|
||||||
|
return keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.media.MediaEventManager
|
import com.audiobookshelf.app.media.MediaEventManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.audiobookshelf.app.managers.SecureStorage
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
@ -24,15 +25,19 @@ class AbsDatabase : Plugin() {
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
lateinit var mainActivity: MainActivity
|
||||||
lateinit var apiHandler: ApiHandler
|
lateinit var apiHandler: ApiHandler
|
||||||
|
lateinit var secureStorage: SecureStorage
|
||||||
|
|
||||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map<String,String>?)
|
||||||
|
|
||||||
override fun load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
|
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
|
||||||
|
|
||||||
|
secureStorage = SecureStorage(mainActivity)
|
||||||
|
|
||||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||||
|
@ -120,7 +125,8 @@ class AbsDatabase : Plugin() {
|
||||||
|
|
||||||
val userId = serverConfigPayload.userId
|
val userId = serverConfigPayload.userId
|
||||||
val username = serverConfigPayload.username
|
val username = serverConfigPayload.username
|
||||||
val token = serverConfigPayload.token
|
val accessToken = serverConfigPayload.token // New token
|
||||||
|
val refreshToken = serverConfigPayload.refreshToken // Refresh only sent on first connection
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
if (serverConnectionConfig == null) { // New Server Connection
|
if (serverConnectionConfig == null) { // New Server Connection
|
||||||
|
@ -129,7 +135,16 @@ class AbsDatabase : Plugin() {
|
||||||
// Create new server connection config
|
// Create new server connection config
|
||||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
|
||||||
|
// Store refresh token securely if provided
|
||||||
|
val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) {
|
||||||
|
secureStorage.storeRefreshToken(sscId, refreshToken)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Log.d(tag, "Refresh token secured = $hasRefreshToken")
|
||||||
|
|
||||||
|
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, accessToken, serverConfigPayload.customHeaders)
|
||||||
|
|
||||||
// Add and save
|
// Add and save
|
||||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||||
|
@ -137,14 +152,20 @@ class AbsDatabase : Plugin() {
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
} else {
|
} else {
|
||||||
var shouldSave = false
|
var shouldSave = false
|
||||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken) {
|
||||||
serverConnectionConfig?.userId = userId
|
serverConnectionConfig?.userId = userId
|
||||||
serverConnectionConfig?.username = username
|
serverConnectionConfig?.username = username
|
||||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
||||||
serverConnectionConfig?.token = token
|
serverConnectionConfig?.token = accessToken
|
||||||
shouldSave = true
|
shouldSave = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update refresh token if provided
|
||||||
|
if (!refreshToken.isNullOrEmpty()) {
|
||||||
|
val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken)
|
||||||
|
Log.d(tag, "Refresh token secured = $stored")
|
||||||
|
}
|
||||||
|
|
||||||
// Set last connection config
|
// Set last connection config
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||||
|
@ -163,6 +184,10 @@ class AbsDatabase : Plugin() {
|
||||||
fun removeServerConnectionConfig(call:PluginCall) {
|
fun removeServerConnectionConfig(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
// Remove refresh token if it exists
|
||||||
|
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
|
||||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
|
@ -175,6 +200,32 @@ class AbsDatabase : Plugin() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getRefreshToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||||
|
if (refreshToken != null) {
|
||||||
|
val result = JSObject()
|
||||||
|
result.put("refreshToken", refreshToken)
|
||||||
|
call.resolve(result)
|
||||||
|
} else {
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getAccessToken(call:PluginCall) {
|
||||||
|
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||||
|
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
||||||
|
val token = serverConnectionConfig?.token ?: ""
|
||||||
|
val ret = JSObject()
|
||||||
|
ret.put("token", token)
|
||||||
|
call.resolve(ret)
|
||||||
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun logout(call:PluginCall) {
|
fun logout(call:PluginCall) {
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import com.audiobookshelf.app.media.SyncResult
|
||||||
import com.audiobookshelf.app.models.User
|
import com.audiobookshelf.app.models.User
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.plugins.AbsLogger
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import com.audiobookshelf.app.managers.SecureStorage
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
@ -33,9 +34,19 @@ import java.util.concurrent.TimeUnit
|
||||||
class ApiHandler(var ctx:Context) {
|
class ApiHandler(var ctx:Context) {
|
||||||
val tag = "ApiHandler"
|
val tag = "ApiHandler"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// For sending data back to the Webview frontend
|
||||||
|
lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit
|
||||||
|
|
||||||
|
fun checkAbsDatabaseNotifyListenersInitted():Boolean {
|
||||||
|
return ::absDatabaseNotifyListeners.isInitialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var defaultClient = OkHttpClient()
|
private var defaultClient = OkHttpClient()
|
||||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
private var secureStorage = SecureStorage(ctx)
|
||||||
|
|
||||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
@ -110,6 +121,13 @@ class ApiHandler(var ctx:Context) {
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
response.use {
|
response.use {
|
||||||
|
if (it.code == 401) {
|
||||||
|
// Handle 401 Unauthorized by attempting token refresh
|
||||||
|
Log.d(tag, "Received 401, attempting token refresh")
|
||||||
|
handleTokenRefresh(request, httpClient, cb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!it.isSuccessful) {
|
if (!it.isSuccessful) {
|
||||||
val jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("error", "Unexpected code $response")
|
jsobj.put("error", "Unexpected code $response")
|
||||||
|
@ -142,6 +160,251 @@ class ApiHandler(var ctx:Context) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles token refresh when a 401 Unauthorized response is received
|
||||||
|
* This function will:
|
||||||
|
* 1. Get the refresh token from secure storage for the current server connection
|
||||||
|
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||||
|
* 3. Update the stored tokens with the new access token
|
||||||
|
* 4. Retry the original request with the new access token
|
||||||
|
* 5. If refresh fails, handle logout
|
||||||
|
*
|
||||||
|
* @param originalRequest The original request that failed with 401
|
||||||
|
* @param httpClient The HTTP client to use for the request
|
||||||
|
* @param callback The callback to return the response
|
||||||
|
*/
|
||||||
|
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
Log.d(tag, "handleTokenRefresh: Starting token refresh process")
|
||||||
|
|
||||||
|
// Get current server connection config ID
|
||||||
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
|
if (serverConnectionConfigId.isEmpty()) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: No server connection config ID available")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "No server connection available")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refresh token from secure storage
|
||||||
|
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||||
|
if (refreshToken.isNullOrEmpty()) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: No refresh token available for server $serverConnectionConfigId")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "No refresh token available")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||||
|
|
||||||
|
// Create refresh token request
|
||||||
|
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
||||||
|
val refreshRequest = Request.Builder()
|
||||||
|
.url(refreshEndpoint)
|
||||||
|
.addHeader("Authorization", "Bearer $refreshToken")
|
||||||
|
.addHeader("Content-Type", "application/json")
|
||||||
|
.post(EMPTY_REQUEST)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Make the refresh request
|
||||||
|
val client = httpClient ?: defaultClient
|
||||||
|
client.newCall(refreshRequest).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Refresh request failed with status ${it.code}")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyString = it.body!!.string()
|
||||||
|
try {
|
||||||
|
val responseJson = JSONObject(bodyString)
|
||||||
|
val userObj = responseJson.optJSONObject("user")
|
||||||
|
|
||||||
|
if (userObj == null) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: No user object in refresh response")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val newAccessToken = userObj.optString("accessToken")
|
||||||
|
val newRefreshToken = userObj.optString("refreshToken")
|
||||||
|
|
||||||
|
if (newAccessToken.isEmpty()) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: No access token in refresh response")
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
|
||||||
|
|
||||||
|
// Update tokens in secure storage and device manager
|
||||||
|
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||||
|
|
||||||
|
// Retry the original request with the new access token
|
||||||
|
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
|
||||||
|
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
||||||
|
handleRefreshFailure(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the stored tokens with new access and refresh tokens
|
||||||
|
*
|
||||||
|
* @param newAccessToken The new access token
|
||||||
|
* @param newRefreshToken The new refresh token (or existing one if not provided)
|
||||||
|
*/
|
||||||
|
private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
|
||||||
|
try {
|
||||||
|
// Update the refresh token in secure storage if it's new
|
||||||
|
if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) {
|
||||||
|
secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken)
|
||||||
|
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the access token in the current server connection config
|
||||||
|
DeviceManager.serverConnectionConfig?.let { config ->
|
||||||
|
config.token = newAccessToken
|
||||||
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send access token to Webview frontend
|
||||||
|
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||||
|
val tokenJsObject = JSObject()
|
||||||
|
tokenJsObject.put("accessToken", newAccessToken)
|
||||||
|
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||||
|
} else {
|
||||||
|
// Can happen if Webview is never run
|
||||||
|
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "updateTokens: Failed to update tokens", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries the original request with the new access token
|
||||||
|
*
|
||||||
|
* @param originalRequest The original request to retry
|
||||||
|
* @param newAccessToken The new access token to use
|
||||||
|
* @param httpClient The HTTP client to use
|
||||||
|
* @param callback The callback to return the response
|
||||||
|
*/
|
||||||
|
private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
// Create a new request with the updated authorization header
|
||||||
|
val newRequest = originalRequest.newBuilder()
|
||||||
|
.removeHeader("Authorization")
|
||||||
|
.addHeader("Authorization", "Bearer $newAccessToken")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}")
|
||||||
|
|
||||||
|
// Make the retry request
|
||||||
|
val client = httpClient ?: defaultClient
|
||||||
|
client.newCall(newRequest).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Failed to retry request", e)
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Failed to retry request after token refresh")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Retry request failed with status ${it.code}")
|
||||||
|
callback(errorObj)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bodyString = it.body!!.string()
|
||||||
|
if (bodyString == "OK") {
|
||||||
|
callback(JSObject())
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
var jsonObj = JSObject()
|
||||||
|
if (bodyString.startsWith("[")) {
|
||||||
|
val array = JSArray(bodyString)
|
||||||
|
jsonObj.put("value", array)
|
||||||
|
} else {
|
||||||
|
jsonObj = JSObject(bodyString)
|
||||||
|
}
|
||||||
|
callback(jsonObj)
|
||||||
|
} catch(je:JSONException) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString")
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Invalid response body")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e)
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Failed to retry request")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the case when token refresh fails
|
||||||
|
* This will clear the current session and notify the callback
|
||||||
|
*
|
||||||
|
* @param callback The callback to return the error
|
||||||
|
*/
|
||||||
|
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
||||||
|
try {
|
||||||
|
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
||||||
|
|
||||||
|
// Clear the current server connection
|
||||||
|
DeviceManager.serverConnectionConfig = null
|
||||||
|
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||||
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
|
||||||
|
// Remove refresh token from secure storage
|
||||||
|
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||||
|
if (!serverConnectionConfigId.isNullOrEmpty()) {
|
||||||
|
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Authentication failed - please login again")
|
||||||
|
callback(errorObj)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||||
|
val errorObj = JSObject()
|
||||||
|
errorObj.put("error", "Authentication failed")
|
||||||
|
callback(errorObj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||||
getRequest("/api/me", null, null) {
|
getRequest("/api/me", null, null) {
|
||||||
if (it.has("error")) {
|
if (it.has("error")) {
|
||||||
|
|
|
@ -436,7 +436,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.error = null
|
this.error = null
|
||||||
var payload = await this.authenticateToken()
|
const payload = await this.authenticateToken()
|
||||||
|
|
||||||
if (payload) {
|
if (payload) {
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
|
@ -597,7 +597,7 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
requestServerLogin() {
|
requestServerLogin() {
|
||||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
return this.postRequest(`${this.serverConfig.address}/login?return_tokens=true`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.user) {
|
if (!data.user) {
|
||||||
console.error(data.error)
|
console.error(data.error)
|
||||||
|
@ -806,7 +806,7 @@ export default {
|
||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
var payload = await this.requestServerLogin()
|
const payload = await this.requestServerLogin()
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (payload) {
|
if (payload) {
|
||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
|
@ -830,8 +830,13 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.serverConfig.userId = user.id
|
this.serverConfig.userId = user.id
|
||||||
this.serverConfig.token = user.token
|
|
||||||
this.serverConfig.username = user.username
|
this.serverConfig.username = user.username
|
||||||
|
// Tokens only returned from /login endpoint
|
||||||
|
if (user.accessToken) {
|
||||||
|
this.serverConfig.token = user.accessToken
|
||||||
|
this.serverConfig.refreshToken = user.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
delete this.serverConfig.version
|
delete this.serverConfig.version
|
||||||
|
|
||||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||||
|
@ -850,6 +855,7 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
|
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
|
||||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||||
|
|
||||||
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
||||||
|
@ -865,6 +871,7 @@ export default {
|
||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
|
// TODO: Handle refresh token
|
||||||
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
||||||
console.error('[ServerConnectForm] Server auth failed', error)
|
console.error('[ServerConnectForm] Server auth failed', error)
|
||||||
const errorMsg = error.message || error
|
const errorMsg = error.message || error
|
||||||
|
@ -882,6 +889,7 @@ export default {
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
if (this.lastServerConnectionConfig) {
|
if (this.lastServerConnectionConfig) {
|
||||||
|
console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig)
|
||||||
this.connectToServer(this.lastServerConnectionConfig)
|
this.connectToServer(this.lastServerConnectionConfig)
|
||||||
} else {
|
} else {
|
||||||
this.showForm = !this.serverConnectionConfigs.length
|
this.showForm = !this.serverConnectionConfigs.length
|
||||||
|
|
|
@ -99,21 +99,6 @@ export default {
|
||||||
|
|
||||||
await this.$store.dispatch('user/loadUserSettings')
|
await this.$store.dispatch('user/loadUserSettings')
|
||||||
},
|
},
|
||||||
async postRequest(url, data, headers, connectTimeout = 30000) {
|
|
||||||
const options = {
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
data,
|
|
||||||
connectTimeout
|
|
||||||
}
|
|
||||||
const response = await CapacitorHttp.post(options)
|
|
||||||
console.log('[default] POST request response', response)
|
|
||||||
if (response.status >= 400) {
|
|
||||||
throw new Error(response.data)
|
|
||||||
} else {
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async attemptConnection() {
|
async attemptConnection() {
|
||||||
console.warn('[default] attemptConnection')
|
console.warn('[default] attemptConnection')
|
||||||
if (!this.networkConnected) {
|
if (!this.networkConnected) {
|
||||||
|
@ -145,10 +130,18 @@ export default {
|
||||||
|
|
||||||
AbsLogger.info({ tag: 'default', message: `attemptConnection: Got server config, attempt authorize (${serverConfig.name})` })
|
AbsLogger.info({ tag: 'default', message: `attemptConnection: Got server config, attempt authorize (${serverConfig.name})` })
|
||||||
|
|
||||||
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 6000).catch((error) => {
|
const nativeHttpOptions = {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${serverConfig.token}`
|
||||||
|
},
|
||||||
|
connectTimeout: 6000,
|
||||||
|
serverConnectionConfig: serverConfig
|
||||||
|
}
|
||||||
|
const authRes = await this.$nativeHttp.post(`${serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
|
||||||
AbsLogger.error({ tag: 'default', message: `attemptConnection: Server auth failed (${serverConfig.name})` })
|
AbsLogger.error({ tag: 'default', message: `attemptConnection: Server auth failed (${serverConfig.name})` })
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!authRes) {
|
if (!authRes) {
|
||||||
this.attemptingConnection = false
|
this.attemptingConnection = false
|
||||||
return
|
return
|
||||||
|
@ -168,6 +161,7 @@ export default {
|
||||||
const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
|
const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig)
|
||||||
|
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
|
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
|
||||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||||
|
|
||||||
this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token)
|
this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token)
|
||||||
|
|
|
@ -30,7 +30,7 @@ export default {
|
||||||
|
|
||||||
css: ['@/assets/tailwind.css', '@/assets/app.css'],
|
css: ['@/assets/tailwind.css', '@/assets/app.css'],
|
||||||
|
|
||||||
plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/nativeHttp.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'],
|
plugins: ['@/plugins/server.js', '@/plugins/db.js', '@/plugins/localStore.js', '@/plugins/init.client.js', '@/plugins/axios.js', '@/plugins/capacitor/index.js', '@/plugins/capacitor/AbsAudioPlayer.js', '@/plugins/nativeHttp.js', '@/plugins/toast.js', '@/plugins/constants.js', '@/plugins/haptics.js', '@/plugins/i18n.js'],
|
||||||
|
|
||||||
components: true,
|
components: true,
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
export default function ({ $axios, store }) {
|
export default function ({ $axios, store }) {
|
||||||
$axios.onRequest(config => {
|
$axios.onRequest((config) => {
|
||||||
console.log('[Axios] Making request to ' + config.url)
|
console.log('[Axios] Making request to ' + config.url)
|
||||||
if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) {
|
if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) {
|
||||||
return
|
return
|
||||||
|
@ -26,7 +26,7 @@ export default function ({ $axios, store }) {
|
||||||
console.log('[Axios] Request out', config.url)
|
console.log('[Axios] Request out', config.url)
|
||||||
})
|
})
|
||||||
|
|
||||||
$axios.onError(error => {
|
$axios.onError((error) => {
|
||||||
console.error('Axios error code', error)
|
console.error('Axios error code', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -245,7 +245,7 @@ class AbsAudioPlayerWeb extends WebPlugin {
|
||||||
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
|
this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0))
|
||||||
const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress'])
|
const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress'])
|
||||||
const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}`
|
const serverHost = `${serverAddressUrl.protocol}//${serverAddressUrl.host}`
|
||||||
this.player.src = `${serverHost}${this.currentTrack.contentUrl}?token=${vuexStore.getters['user/getToken']}`
|
this.player.src = `${serverHost}${this.currentTrack.contentUrl}`
|
||||||
console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`)
|
console.log(`[AbsAudioPlayer] Loading track src ${this.player.src}`)
|
||||||
this.player.load()
|
this.player.load()
|
||||||
this.player.playbackRate = this.playbackRate
|
this.player.playbackRate = this.playbackRate
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core';
|
import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core'
|
||||||
|
|
||||||
class AbsDatabaseWeb extends WebPlugin {
|
class AbsDatabaseWeb extends WebPlugin {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -22,7 +22,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
async setCurrentServerConnectionConfig(serverConnectionConfig) {
|
async setCurrentServerConnectionConfig(serverConnectionConfig) {
|
||||||
var deviceData = await this.getDeviceData()
|
var deviceData = await this.getDeviceData()
|
||||||
|
|
||||||
var ssc = deviceData.serverConnectionConfigs.find(_ssc => _ssc.id == serverConnectionConfig.id)
|
var ssc = deviceData.serverConnectionConfigs.find((_ssc) => _ssc.id == serverConnectionConfig.id)
|
||||||
if (ssc) {
|
if (ssc) {
|
||||||
deviceData.lastServerConnectionConfigId = ssc.id
|
deviceData.lastServerConnectionConfigId = ssc.id
|
||||||
ssc.name = `${ssc.address} (${serverConnectionConfig.username})`
|
ssc.name = `${ssc.address} (${serverConnectionConfig.username})`
|
||||||
|
@ -30,6 +30,13 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
ssc.userId = serverConnectionConfig.userId
|
ssc.userId = serverConnectionConfig.userId
|
||||||
ssc.username = serverConnectionConfig.username
|
ssc.username = serverConnectionConfig.username
|
||||||
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
ssc.customHeaders = serverConnectionConfig.customHeaders || {}
|
||||||
|
|
||||||
|
if (serverConnectionConfig.refreshToken) {
|
||||||
|
console.log('[AbsDatabase] Updating refresh token...', serverConnectionConfig.refreshToken)
|
||||||
|
// Only using local storage for web version that is only used for testing
|
||||||
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||||
} else {
|
} else {
|
||||||
ssc = {
|
ssc = {
|
||||||
|
@ -42,6 +49,13 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
token: serverConnectionConfig.token,
|
token: serverConnectionConfig.token,
|
||||||
customHeaders: serverConnectionConfig.customHeaders || {}
|
customHeaders: serverConnectionConfig.customHeaders || {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (serverConnectionConfig.refreshToken) {
|
||||||
|
console.log('[AbsDatabase] Setting refresh token...', serverConnectionConfig.refreshToken)
|
||||||
|
// Only using local storage for web version that is only used for testing
|
||||||
|
localStorage.setItem(`refresh_token_${ssc.id}`, serverConnectionConfig.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
deviceData.serverConnectionConfigs.push(ssc)
|
deviceData.serverConnectionConfigs.push(ssc)
|
||||||
deviceData.lastServerConnectionConfigId = ssc.id
|
deviceData.lastServerConnectionConfigId = ssc.id
|
||||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||||
|
@ -49,10 +63,16 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
return ssc
|
return ssc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRefreshToken({ serverConnectionConfigId }) {
|
||||||
|
console.log('[AbsDatabase] Getting refresh token...', serverConnectionConfigId)
|
||||||
|
const refreshToken = localStorage.getItem(`refresh_token_${serverConnectionConfigId}`)
|
||||||
|
return refreshToken ? { refreshToken } : null
|
||||||
|
}
|
||||||
|
|
||||||
async removeServerConnectionConfig(serverConnectionConfigCallObject) {
|
async removeServerConnectionConfig(serverConnectionConfigCallObject) {
|
||||||
var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId
|
var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId
|
||||||
var deviceData = await this.getDeviceData()
|
var deviceData = await this.getDeviceData()
|
||||||
deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter(ssc => ssc.id != serverConnectionConfigId)
|
deviceData.serverConnectionConfigs = deviceData.serverConnectionConfigs.filter((ssc) => ssc.id != serverConnectionConfigId)
|
||||||
localStorage.setItem('device', JSON.stringify(deviceData))
|
localStorage.setItem('device', JSON.stringify(deviceData))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,79 +105,81 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
}
|
}
|
||||||
async getLocalLibraryItems(payload) {
|
async getLocalLibraryItems(payload) {
|
||||||
return {
|
return {
|
||||||
value: [{
|
value: [
|
||||||
id: 'local_test',
|
{
|
||||||
libraryItemId: 'test34',
|
id: 'local_test',
|
||||||
serverAddress: 'https://abs.test.com',
|
libraryItemId: 'test34',
|
||||||
serverUserId: 'test56',
|
serverAddress: 'https://abs.test.com',
|
||||||
folderId: 'test1',
|
serverUserId: 'test56',
|
||||||
absolutePath: 'a',
|
folderId: 'test1',
|
||||||
contentUrl: 'c',
|
absolutePath: 'a',
|
||||||
isInvalid: false,
|
contentUrl: 'c',
|
||||||
mediaType: 'book',
|
isInvalid: false,
|
||||||
media: {
|
mediaType: 'book',
|
||||||
metadata: {
|
media: {
|
||||||
title: 'Test Book',
|
metadata: {
|
||||||
authorName: 'Test Author Name'
|
title: 'Test Book',
|
||||||
|
authorName: 'Test Author Name'
|
||||||
|
},
|
||||||
|
coverPath: null,
|
||||||
|
tags: [],
|
||||||
|
audioFiles: [],
|
||||||
|
chapters: [],
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
startOffset: 0,
|
||||||
|
duration: 10000,
|
||||||
|
title: 'Track Title 1',
|
||||||
|
contentUrl: 'test',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
metadata: null,
|
||||||
|
isLocal: true,
|
||||||
|
localFileId: 'lf1',
|
||||||
|
audioProbeResult: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 2,
|
||||||
|
startOffset: 0,
|
||||||
|
duration: 15000,
|
||||||
|
title: 'Track Title 2',
|
||||||
|
contentUrl: 'test2',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
metadata: null,
|
||||||
|
isLocal: true,
|
||||||
|
localFileId: 'lf2',
|
||||||
|
audioProbeResult: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
index: 3,
|
||||||
|
startOffset: 0,
|
||||||
|
duration: 20000,
|
||||||
|
title: 'Track Title 3',
|
||||||
|
contentUrl: 'test3',
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
metadata: null,
|
||||||
|
isLocal: true,
|
||||||
|
localFileId: 'lf3',
|
||||||
|
audioProbeResult: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
coverPath: null,
|
localFiles: [
|
||||||
tags: [],
|
|
||||||
audioFiles: [],
|
|
||||||
chapters: [],
|
|
||||||
tracks: [
|
|
||||||
{
|
{
|
||||||
index: 1,
|
id: 'lf1',
|
||||||
startOffset: 0,
|
filename: 'lf1.mp3',
|
||||||
duration: 10000,
|
|
||||||
title: 'Track Title 1',
|
|
||||||
contentUrl: 'test',
|
contentUrl: 'test',
|
||||||
|
absolutePath: 'test',
|
||||||
|
simplePath: 'test',
|
||||||
mimeType: 'audio/mpeg',
|
mimeType: 'audio/mpeg',
|
||||||
metadata: null,
|
size: 39048290
|
||||||
isLocal: true,
|
|
||||||
localFileId: 'lf1',
|
|
||||||
audioProbeResult: {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 2,
|
|
||||||
startOffset: 0,
|
|
||||||
duration: 15000,
|
|
||||||
title: 'Track Title 2',
|
|
||||||
contentUrl: 'test2',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
metadata: null,
|
|
||||||
isLocal: true,
|
|
||||||
localFileId: 'lf2',
|
|
||||||
audioProbeResult: {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 3,
|
|
||||||
startOffset: 0,
|
|
||||||
duration: 20000,
|
|
||||||
title: 'Track Title 3',
|
|
||||||
contentUrl: 'test3',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
metadata: null,
|
|
||||||
isLocal: true,
|
|
||||||
localFileId: 'lf3',
|
|
||||||
audioProbeResult: {}
|
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
},
|
coverContentUrl: null,
|
||||||
localFiles: [
|
coverAbsolutePath: null,
|
||||||
{
|
isLocal: true
|
||||||
id: 'lf1',
|
}
|
||||||
filename: 'lf1.mp3',
|
]
|
||||||
contentUrl: 'test',
|
|
||||||
absolutePath: 'test',
|
|
||||||
simplePath: 'test',
|
|
||||||
mimeType: 'audio/mpeg',
|
|
||||||
size: 39048290
|
|
||||||
}
|
|
||||||
],
|
|
||||||
coverContentUrl: null,
|
|
||||||
coverAbsolutePath: null,
|
|
||||||
isLocal: true
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getLocalLibraryItemsInFolder({ folderId }) {
|
async getLocalLibraryItemsInFolder({ folderId }) {
|
||||||
|
@ -167,7 +189,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
return this.getLocalLibraryItems().then((data) => data.value[0])
|
return this.getLocalLibraryItems().then((data) => data.value[0])
|
||||||
}
|
}
|
||||||
async getLocalLibraryItemByLId({ libraryItemId }) {
|
async getLocalLibraryItemByLId({ libraryItemId }) {
|
||||||
return this.getLocalLibraryItems().then((data) => data.value.find(lli => lli.libraryItemId == libraryItemId))
|
return this.getLocalLibraryItems().then((data) => data.value.find((lli) => lli.libraryItemId == libraryItemId))
|
||||||
}
|
}
|
||||||
async getAllLocalMediaProgress() {
|
async getAllLocalMediaProgress() {
|
||||||
return {
|
return {
|
||||||
|
@ -182,7 +204,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
isFinished: false,
|
isFinished: false,
|
||||||
lastUpdate: 394089090,
|
lastUpdate: 394089090,
|
||||||
startedAt: 239048209,
|
startedAt: 239048209,
|
||||||
finishedAt: null,
|
finishedAt: null
|
||||||
// For local lib items from server to support server sync
|
// For local lib items from server to support server sync
|
||||||
// var serverConnectionConfigId:String?,
|
// var serverConnectionConfigId:String?,
|
||||||
// var serverAddress:String?,
|
// var serverAddress:String?,
|
||||||
|
@ -240,7 +262,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 22) + 13000 // 22 mins ago + 13s
|
timestamp: Date.now() - 1000 * 60 * 22 + 13000 // 22 mins ago + 13s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
|
@ -250,7 +272,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago
|
timestamp: Date.now() - 1000 * 60 * 22 // 22 mins ago
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Pause',
|
name: 'Pause',
|
||||||
|
@ -260,7 +282,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: false,
|
serverSyncSuccess: false,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (58000) // 1 hour ago + 58s
|
timestamp: Date.now() - 1000 * 60 * 60 + 58000 // 1 hour ago + 58s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
|
@ -270,7 +292,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (45000) // 1 hour ago + 45s
|
timestamp: Date.now() - 1000 * 60 * 60 + 45000 // 1 hour ago + 45s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
|
@ -280,7 +302,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (30000) // 1 hour ago + 30s
|
timestamp: Date.now() - 1000 * 60 * 60 + 30000 // 1 hour ago + 30s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
|
@ -290,7 +312,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) + (15000) // 1 hour ago + 15s
|
timestamp: Date.now() - 1000 * 60 * 60 + 15000 // 1 hour ago + 15s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
|
@ -300,7 +322,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago
|
timestamp: Date.now() - 1000 * 60 * 60 // 1 hour ago
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Stop',
|
name: 'Stop',
|
||||||
|
@ -310,7 +332,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 10000 // 25 hours ago + 10s
|
timestamp: Date.now() - 1000 * 60 * 60 * 25 + 10000 // 25 hours ago + 10s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Seek',
|
name: 'Seek',
|
||||||
|
@ -320,7 +342,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: true,
|
serverSyncAttempted: true,
|
||||||
serverSyncSuccess: true,
|
serverSyncSuccess: true,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 25) + 2000 // 25 hours ago + 2s
|
timestamp: Date.now() - 1000 * 60 * 60 * 25 + 2000 // 25 hours ago + 2s
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
|
@ -330,7 +352,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 25) // 25 hours ago
|
timestamp: Date.now() - 1000 * 60 * 60 * 25 // 25 hours ago
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Play',
|
name: 'Play',
|
||||||
|
@ -340,7 +362,7 @@ class AbsDatabaseWeb extends WebPlugin {
|
||||||
serverSyncAttempted: false,
|
serverSyncAttempted: false,
|
||||||
serverSyncSuccess: null,
|
serverSyncSuccess: null,
|
||||||
serverSyncMessage: null,
|
serverSyncMessage: null,
|
||||||
timestamp: Date.now() - (1000 * 60 * 60 * 50) // 50 hours ago
|
timestamp: Date.now() - 1000 * 60 * 60 * 50 // 50 hours ago
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -351,4 +373,4 @@ const AbsDatabase = registerPlugin('AbsDatabase', {
|
||||||
web: () => new AbsDatabaseWeb()
|
web: () => new AbsDatabaseWeb()
|
||||||
})
|
})
|
||||||
|
|
||||||
export { AbsDatabase }
|
export { AbsDatabase }
|
||||||
|
|
|
@ -2,9 +2,10 @@ import Vue from 'vue'
|
||||||
import { AbsAudioPlayer } from './AbsAudioPlayer'
|
import { AbsAudioPlayer } from './AbsAudioPlayer'
|
||||||
import { AbsDownloader } from './AbsDownloader'
|
import { AbsDownloader } from './AbsDownloader'
|
||||||
import { AbsFileSystem } from './AbsFileSystem'
|
import { AbsFileSystem } from './AbsFileSystem'
|
||||||
|
import { AbsDatabase } from './AbsDatabase'
|
||||||
import { AbsLogger } from './AbsLogger'
|
import { AbsLogger } from './AbsLogger'
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
|
||||||
Vue.prototype.$platform = Capacitor.getPlatform()
|
Vue.prototype.$platform = Capacitor.getPlatform()
|
||||||
|
|
||||||
export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger }
|
export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger, AbsDatabase }
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { AbsDatabase } from './capacitor/AbsDatabase'
|
import { AbsDatabase } from './capacitor/AbsDatabase'
|
||||||
|
|
||||||
class DbService {
|
class DbService {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
getDeviceData() {
|
getDeviceData() {
|
||||||
return AbsDatabase.getDeviceData().then((data) => {
|
return AbsDatabase.getDeviceData().then((data) => {
|
||||||
|
@ -29,10 +29,12 @@ class DbService {
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocalFolders() {
|
getLocalFolders() {
|
||||||
return AbsDatabase.getLocalFolders().then((data) => data.value).catch((error) => {
|
return AbsDatabase.getLocalFolders()
|
||||||
console.error('Failed to load', error)
|
.then((data) => data.value)
|
||||||
return null
|
.catch((error) => {
|
||||||
})
|
console.error('Failed to load', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocalFolder(folderId) {
|
getLocalFolder(folderId) {
|
||||||
|
@ -103,4 +105,10 @@ class DbService {
|
||||||
|
|
||||||
export default ({ app, store }, inject) => {
|
export default ({ app, store }, inject) => {
|
||||||
inject('db', new DbService())
|
inject('db', new DbService())
|
||||||
}
|
|
||||||
|
// Listen for token refresh events from native app
|
||||||
|
AbsDatabase.addListener('onTokenRefresh', (data) => {
|
||||||
|
console.log('[db] onTokenRefresh', data)
|
||||||
|
store.commit('user/setAccessToken', data.accessToken)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
import { CapacitorHttp } from '@capacitor/core'
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
|
import { AbsDatabase } from '@/plugins/capacitor'
|
||||||
|
|
||||||
export default function ({ store }, inject) {
|
export default function ({ store }, inject) {
|
||||||
const nativeHttp = {
|
const nativeHttp = {
|
||||||
request(method, _url, data, options = {}) {
|
async request(method, _url, data, options = {}) {
|
||||||
|
// When authorizing before a config is set, server config gets passed in as an option
|
||||||
|
let serverConnectionConfig = options.serverConnectionConfig || store.state.user.serverConnectionConfig
|
||||||
|
delete options.serverConnectionConfig
|
||||||
|
|
||||||
let url = _url
|
let url = _url
|
||||||
const headers = {}
|
const headers = {}
|
||||||
if (!url.startsWith('http') && !url.startsWith('capacitor')) {
|
if (!url.startsWith('http') && !url.startsWith('capacitor')) {
|
||||||
|
@ -12,9 +17,8 @@ export default function ({ store }, inject) {
|
||||||
} else {
|
} else {
|
||||||
console.warn('[nativeHttp] No Bearer Token for request')
|
console.warn('[nativeHttp] No Bearer Token for request')
|
||||||
}
|
}
|
||||||
const serverUrl = store.getters['user/getServerAddress']
|
if (serverConnectionConfig?.address) {
|
||||||
if (serverUrl) {
|
url = `${serverConnectionConfig.address}${url}`
|
||||||
url = `${serverUrl}${url}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data) {
|
if (data) {
|
||||||
|
@ -27,7 +31,12 @@ export default function ({ store }, inject) {
|
||||||
data,
|
data,
|
||||||
headers,
|
headers,
|
||||||
...options
|
...options
|
||||||
}).then(res => {
|
}).then((res) => {
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.error(`[nativeHttp] 401 status for url "${url}"`)
|
||||||
|
// Handle refresh token automatically
|
||||||
|
return this.handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig)
|
||||||
|
}
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
|
console.error(`[nativeHttp] ${res.status} status for url "${url}"`)
|
||||||
throw new Error(res.data)
|
throw new Error(res.data)
|
||||||
|
@ -35,6 +44,206 @@ export default function ({ store }, inject) {
|
||||||
return res.data
|
return res.data
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles token refresh when a 401 Unauthorized response is received
|
||||||
|
* @param {string} method - HTTP method
|
||||||
|
* @param {string} url - Full URL
|
||||||
|
* @param {*} data - Request data
|
||||||
|
* @param {Object} headers - Request headers
|
||||||
|
* @param {Object} options - Additional options
|
||||||
|
* @param {{ id: string, address: string }} serverConnectionConfig
|
||||||
|
* @returns {Promise} - Promise that resolves with the response data
|
||||||
|
*/
|
||||||
|
async handleTokenRefresh(method, url, data, headers, options, serverConnectionConfig) {
|
||||||
|
try {
|
||||||
|
console.log('[nativeHttp] Attempting to refresh token...')
|
||||||
|
|
||||||
|
if (!serverConnectionConfig?.id) {
|
||||||
|
console.error('[nativeHttp] No server connection config ID available for token refresh')
|
||||||
|
throw new Error('No server connection available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get refresh token from secure storage
|
||||||
|
const refreshTokenData = await this.getRefreshToken(serverConnectionConfig.id)
|
||||||
|
if (!refreshTokenData || !refreshTokenData.refreshToken) {
|
||||||
|
console.error('[nativeHttp] No refresh token available')
|
||||||
|
throw new Error('No refresh token available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to refresh the token
|
||||||
|
const newTokens = await this.refreshAccessToken(refreshTokenData.refreshToken, serverConnectionConfig.address)
|
||||||
|
if (!newTokens || !newTokens.accessToken) {
|
||||||
|
console.error('[nativeHttp] Failed to refresh access token')
|
||||||
|
throw new Error('Failed to refresh access token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the store with new tokens
|
||||||
|
await this.updateTokens(newTokens, serverConnectionConfig)
|
||||||
|
|
||||||
|
// Retry the original request with the new token
|
||||||
|
console.log('[nativeHttp] Retrying original request with new token...')
|
||||||
|
const newHeaders = options?.headers ? { ...options.headers } : { ...headers }
|
||||||
|
newHeaders['Authorization'] = `Bearer ${newTokens.accessToken}`
|
||||||
|
|
||||||
|
const retryResponse = await CapacitorHttp.request({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
data,
|
||||||
|
...options,
|
||||||
|
headers: newHeaders
|
||||||
|
})
|
||||||
|
|
||||||
|
if (retryResponse.status >= 400) {
|
||||||
|
console.error(`[nativeHttp] Retry request failed with status ${retryResponse.status}`)
|
||||||
|
throw new Error(retryResponse.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return retryResponse.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Token refresh failed:', error)
|
||||||
|
|
||||||
|
// If refresh fails, redirect to login
|
||||||
|
await this.handleRefreshFailure()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves refresh token from secure storage
|
||||||
|
* @param {string} serverConnectionConfigId - Server connection config ID
|
||||||
|
* @returns {Promise<Object|null>} - Promise that resolves with refresh token data or null
|
||||||
|
*/
|
||||||
|
async getRefreshToken(serverConnectionConfigId) {
|
||||||
|
try {
|
||||||
|
console.log('[nativeHttp] Getting refresh token...')
|
||||||
|
return await AbsDatabase.getRefreshToken({ serverConnectionConfigId })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to get refresh token:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the access token using the refresh token
|
||||||
|
* @param {string} refreshToken - The refresh token
|
||||||
|
* @param {string} serverAddress - The server address
|
||||||
|
* @returns {Promise<Object|null>} - Promise that resolves with new tokens or null
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(refreshToken, serverAddress) {
|
||||||
|
try {
|
||||||
|
if (!serverAddress) {
|
||||||
|
throw new Error('No server address available')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[nativeHttp] Refreshing access token...')
|
||||||
|
|
||||||
|
const response = await CapacitorHttp.post({
|
||||||
|
url: `${serverAddress}/auth/refresh`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${refreshToken}`,
|
||||||
|
'X-Return-Tokens': 'true'
|
||||||
|
},
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
console.error('[nativeHttp] Token refresh request failed:', response.status)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResponseData = response.data
|
||||||
|
if (!userResponseData.user?.accessToken) {
|
||||||
|
console.error('[nativeHttp] No access token in refresh response')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[nativeHttp] Successfully refreshed access token')
|
||||||
|
return {
|
||||||
|
accessToken: userResponseData.user.accessToken,
|
||||||
|
refreshToken: userResponseData.user.refreshToken || refreshToken // Use new refresh token if provided, otherwise keep the old one
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to refresh access token:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the store and secure storage with new tokens
|
||||||
|
* @param {Object} tokens - Object containing accessToken and refreshToken
|
||||||
|
* @param {{ id: string, address: string }} serverConnectionConfig
|
||||||
|
* @returns {Promise} - Promise that resolves when tokens are updated
|
||||||
|
*/
|
||||||
|
async updateTokens(tokens, serverConnectionConfig) {
|
||||||
|
try {
|
||||||
|
if (!serverConnectionConfig?.id) {
|
||||||
|
throw new Error('No server connection config ID available')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the config with new tokens
|
||||||
|
const updatedConfig = {
|
||||||
|
...serverConnectionConfig,
|
||||||
|
token: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated config to secure storage
|
||||||
|
const savedConfig = await AbsDatabase.setCurrentServerConnectionConfig(updatedConfig)
|
||||||
|
|
||||||
|
// Update the store
|
||||||
|
store.commit('user/setAccessToken', tokens.accessToken)
|
||||||
|
|
||||||
|
if (savedConfig) {
|
||||||
|
store.commit('user/setServerConnectionConfig', savedConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[nativeHttp] Successfully updated tokens in store and secure storage')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to update tokens:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the case when token refresh fails
|
||||||
|
* @returns {Promise} - Promise that resolves when logout is complete
|
||||||
|
*/
|
||||||
|
async handleRefreshFailure() {
|
||||||
|
try {
|
||||||
|
console.log('[nativeHttp] Handling refresh failure - logging out user')
|
||||||
|
|
||||||
|
// Clear the store
|
||||||
|
store.commit('user/setUser', null)
|
||||||
|
store.commit('user/setAccessToken', null)
|
||||||
|
store.commit('user/setServerConnectionConfig', null)
|
||||||
|
|
||||||
|
// Logout from database
|
||||||
|
await AbsDatabase.logout()
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
|
if (window.location.pathname !== '/connect') {
|
||||||
|
window.location.href = '/connect'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to handle refresh failure:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets device data from the database
|
||||||
|
* @returns {Promise<Object>} - Promise that resolves with device data
|
||||||
|
*/
|
||||||
|
async getDeviceData() {
|
||||||
|
try {
|
||||||
|
return await AbsDatabase.getDeviceData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[nativeHttp] Failed to get device data:', error)
|
||||||
|
return { serverConnectionConfigs: [] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
get(url, options = {}) {
|
get(url, options = {}) {
|
||||||
return this.request('GET', url, undefined, options)
|
return this.request('GET', url, undefined, options)
|
||||||
},
|
},
|
||||||
|
@ -49,4 +258,4 @@ export default function ({ store }, inject) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
inject('nativeHttp', nativeHttp)
|
inject('nativeHttp', nativeHttp)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { Browser } from '@capacitor/browser'
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
user: null,
|
user: null,
|
||||||
|
accessToken: null,
|
||||||
serverConnectionConfig: null,
|
serverConnectionConfig: null,
|
||||||
settings: {
|
settings: {
|
||||||
mobileOrderBy: 'addedAt',
|
mobileOrderBy: 'addedAt',
|
||||||
|
@ -17,7 +18,7 @@ export const getters = {
|
||||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||||
getToken: (state) => {
|
getToken: (state) => {
|
||||||
return state.user?.token || null
|
return state.accessToken || null
|
||||||
},
|
},
|
||||||
getServerConnectionConfigId: (state) => {
|
getServerConnectionConfigId: (state) => {
|
||||||
return state.serverConnectionConfig?.id || null
|
return state.serverConnectionConfig?.id || null
|
||||||
|
@ -31,16 +32,18 @@ export const getters = {
|
||||||
getCustomHeaders: (state) => {
|
getCustomHeaders: (state) => {
|
||||||
return state.serverConnectionConfig?.customHeaders || null
|
return state.serverConnectionConfig?.customHeaders || null
|
||||||
},
|
},
|
||||||
getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
|
getUserMediaProgress:
|
||||||
if (!state.user?.mediaProgress) return null
|
(state) =>
|
||||||
return state.user.mediaProgress.find(li => {
|
(libraryItemId, episodeId = null) => {
|
||||||
if (episodeId && li.episodeId !== episodeId) return false
|
if (!state.user?.mediaProgress) return null
|
||||||
return li.libraryItemId == libraryItemId
|
return state.user.mediaProgress.find((li) => {
|
||||||
})
|
if (episodeId && li.episodeId !== episodeId) return false
|
||||||
},
|
return li.libraryItemId == libraryItemId
|
||||||
|
})
|
||||||
|
},
|
||||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||||
if (!state?.user?.bookmarks) return []
|
if (!state?.user?.bookmarks) return []
|
||||||
return state.user.bookmarks.filter(bm => bm.libraryItemId === libraryItemId)
|
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||||
},
|
},
|
||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
return state.settings?.[key] || null
|
return state.settings?.[key] || null
|
||||||
|
@ -143,13 +146,17 @@ export const mutations = {
|
||||||
setUser(state, user) {
|
setUser(state, user) {
|
||||||
state.user = user
|
state.user = user
|
||||||
},
|
},
|
||||||
|
setAccessToken(state, accessToken) {
|
||||||
|
console.log('[user] setAccessToken', accessToken)
|
||||||
|
state.accessToken = accessToken
|
||||||
|
},
|
||||||
removeMediaProgress(state, id) {
|
removeMediaProgress(state, id) {
|
||||||
if (!state.user) return
|
if (!state.user) return
|
||||||
state.user.mediaProgress = state.user.mediaProgress.filter(mp => mp.id != id)
|
state.user.mediaProgress = state.user.mediaProgress.filter((mp) => mp.id != id)
|
||||||
},
|
},
|
||||||
updateUserMediaProgress(state, data) {
|
updateUserMediaProgress(state, data) {
|
||||||
if (!data || !state.user) return
|
if (!data || !state.user) return
|
||||||
const mediaProgressIndex = state.user.mediaProgress.findIndex(mp => mp.id === data.id)
|
const mediaProgressIndex = state.user.mediaProgress.findIndex((mp) => mp.id === data.id)
|
||||||
if (mediaProgressIndex >= 0) {
|
if (mediaProgressIndex >= 0) {
|
||||||
state.user.mediaProgress.splice(mediaProgressIndex, 1, data)
|
state.user.mediaProgress.splice(mediaProgressIndex, 1, data)
|
||||||
} else {
|
} else {
|
||||||
|
@ -174,9 +181,9 @@ export const mutations = {
|
||||||
},
|
},
|
||||||
deleteBookmark(state, { libraryItemId, time }) {
|
deleteBookmark(state, { libraryItemId, time }) {
|
||||||
if (!state.user?.bookmarks) return
|
if (!state.user?.bookmarks) return
|
||||||
state.user.bookmarks = state.user.bookmarks.filter(bm => {
|
state.user.bookmarks = state.user.bookmarks.filter((bm) => {
|
||||||
if (bm.libraryItemId === libraryItemId && bm.time === time) return false
|
if (bm.libraryItemId === libraryItemId && bm.time === time) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue