diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt new file mode 100644 index 00000000..c294f52e --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index bcf0e34d..39a0b650 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.media.MediaEventManager import com.audiobookshelf.app.server.ApiHandler +import com.audiobookshelf.app.managers.SecureStorage import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -24,15 +25,19 @@ class AbsDatabase : Plugin() { lateinit var mainActivity: MainActivity lateinit var apiHandler: ApiHandler + lateinit var secureStorage: SecureStorage data class LocalMediaProgressPayload(val value:List) data class LocalLibraryItemsPayload(val value:List) data class LocalFoldersPayload(val value:List) - 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?) + 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?) override fun load() { mainActivity = (activity as MainActivity) apiHandler = ApiHandler(mainActivity) + ApiHandler.absDatabaseNotifyListeners = ::notifyListeners + + secureStorage = SecureStorage(mainActivity) DeviceManager.dbManager.cleanLocalMediaProgress() DeviceManager.dbManager.cleanLocalLibraryItems() @@ -120,7 +125,8 @@ class AbsDatabase : Plugin() { val userId = serverConfigPayload.userId 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) { if (serverConnectionConfig == null) { // New Server Connection @@ -129,7 +135,16 @@ class AbsDatabase : Plugin() { // Create new server connection config val sscId = DeviceManager.getBase64Id("$serverAddress@$username") 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 DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) @@ -137,14 +152,20 @@ class AbsDatabase : Plugin() { DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) } else { var shouldSave = false - if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) { + if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken) { serverConnectionConfig?.userId = userId serverConnectionConfig?.username = username serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" - serverConnectionConfig?.token = token + serverConnectionConfig?.token = accessToken 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 if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) { DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id @@ -163,6 +184,10 @@ class AbsDatabase : Plugin() { fun removeServerConnectionConfig(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { 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 if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { 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 fun logout(call:PluginCall) { GlobalScope.launch(Dispatchers.IO) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt index 4086d6b7..64841c0b 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -14,6 +14,7 @@ import com.audiobookshelf.app.media.SyncResult import com.audiobookshelf.app.models.User import com.audiobookshelf.app.BuildConfig import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.managers.SecureStorage import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper @@ -33,9 +34,19 @@ import java.util.concurrent.TimeUnit class ApiHandler(var ctx:Context) { 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 pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private var secureStorage = SecureStorage(ctx) data class LocalSessionsSyncRequestPayload(val sessions:List, val deviceInfo:DeviceInfo) @JsonIgnoreProperties(ignoreUnknown = true) @@ -110,6 +121,13 @@ class ApiHandler(var ctx:Context) { override fun onResponse(call: Call, response: Response) { 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) { val jsobj = JSObject() 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) { getRequest("/api/me", null, null) { if (it.has("error")) { diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index 0c35b158..1408797f 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -436,7 +436,7 @@ export default { } this.error = null - var payload = await this.authenticateToken() + const payload = await this.authenticateToken() if (payload) { this.setUserAndConnection(payload) @@ -597,7 +597,7 @@ export default { }) }, 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) => { if (!data.user) { console.error(data.error) @@ -806,7 +806,7 @@ export default { this.error = null this.processing = true - var payload = await this.requestServerLogin() + const payload = await this.requestServerLogin() this.processing = false if (payload) { this.setUserAndConnection(payload) @@ -830,8 +830,13 @@ export default { } this.serverConfig.userId = user.id - this.serverConfig.token = user.token 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 var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig) @@ -850,6 +855,7 @@ export default { } this.$store.commit('user/setUser', user) + this.$store.commit('user/setAccessToken', serverConnectionConfig.token) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$socket.connect(this.serverConfig.address, this.serverConfig.token) @@ -865,6 +871,7 @@ export default { this.error = null 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) => { console.error('[ServerConnectForm] Server auth failed', error) const errorMsg = error.message || error @@ -882,6 +889,7 @@ export default { }, init() { if (this.lastServerConnectionConfig) { + console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig) this.connectToServer(this.lastServerConnectionConfig) } else { this.showForm = !this.serverConnectionConfigs.length diff --git a/layouts/default.vue b/layouts/default.vue index 09a9355b..23eed383 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -99,21 +99,6 @@ export default { 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() { console.warn('[default] attemptConnection') if (!this.networkConnected) { @@ -145,10 +130,18 @@ export default { 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})` }) return false }) + if (!authRes) { this.attemptingConnection = false return @@ -168,6 +161,7 @@ export default { const serverConnectionConfig = await this.$db.setServerConnectionConfig(serverConfig) this.$store.commit('user/setUser', user) + this.$store.commit('user/setAccessToken', serverConnectionConfig.token) this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig) this.$socket.connect(serverConnectionConfig.address, serverConnectionConfig.token) diff --git a/nuxt.config.js b/nuxt.config.js index 60b108a5..09cab389 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -30,7 +30,7 @@ export default { 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, diff --git a/plugins/axios.js b/plugins/axios.js index 73b97238..5974874f 100644 --- a/plugins/axios.js +++ b/plugins/axios.js @@ -1,5 +1,5 @@ export default function ({ $axios, store }) { - $axios.onRequest(config => { + $axios.onRequest((config) => { console.log('[Axios] Making request to ' + config.url) if (config.url.startsWith('http:') || config.url.startsWith('https:') || config.url.startsWith('capacitor:')) { return @@ -26,7 +26,7 @@ export default function ({ $axios, store }) { console.log('[Axios] Request out', config.url) }) - $axios.onError(error => { + $axios.onError((error) => { console.error('Axios error code', error) }) -} \ No newline at end of file +} diff --git a/plugins/capacitor/AbsAudioPlayer.js b/plugins/capacitor/AbsAudioPlayer.js index 43326165..b00dde3f 100644 --- a/plugins/capacitor/AbsAudioPlayer.js +++ b/plugins/capacitor/AbsAudioPlayer.js @@ -245,7 +245,7 @@ class AbsAudioPlayerWeb extends WebPlugin { this.trackStartTime = Math.max(0, this.startTime - (this.currentTrack.startOffset || 0)) const serverAddressUrl = new URL(vuexStore.getters['user/getServerAddress']) 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}`) this.player.load() this.player.playbackRate = this.playbackRate diff --git a/plugins/capacitor/AbsDatabase.js b/plugins/capacitor/AbsDatabase.js index fc34a6fa..971f7ee0 100644 --- a/plugins/capacitor/AbsDatabase.js +++ b/plugins/capacitor/AbsDatabase.js @@ -1,4 +1,4 @@ -import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core'; +import { registerPlugin, Capacitor, WebPlugin } from '@capacitor/core' class AbsDatabaseWeb extends WebPlugin { constructor() { @@ -22,7 +22,7 @@ class AbsDatabaseWeb extends WebPlugin { async setCurrentServerConnectionConfig(serverConnectionConfig) { 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) { deviceData.lastServerConnectionConfigId = ssc.id ssc.name = `${ssc.address} (${serverConnectionConfig.username})` @@ -30,6 +30,13 @@ class AbsDatabaseWeb extends WebPlugin { ssc.userId = serverConnectionConfig.userId ssc.username = serverConnectionConfig.username 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)) } else { ssc = { @@ -42,6 +49,13 @@ class AbsDatabaseWeb extends WebPlugin { token: serverConnectionConfig.token, 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.lastServerConnectionConfigId = ssc.id localStorage.setItem('device', JSON.stringify(deviceData)) @@ -49,10 +63,16 @@ class AbsDatabaseWeb extends WebPlugin { 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) { var serverConnectionConfigId = serverConnectionConfigCallObject.serverConnectionConfigId 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)) } @@ -85,79 +105,81 @@ class AbsDatabaseWeb extends WebPlugin { } async getLocalLibraryItems(payload) { return { - value: [{ - id: 'local_test', - libraryItemId: 'test34', - serverAddress: 'https://abs.test.com', - serverUserId: 'test56', - folderId: 'test1', - absolutePath: 'a', - contentUrl: 'c', - isInvalid: false, - mediaType: 'book', - media: { - metadata: { - title: 'Test Book', - authorName: 'Test Author Name' + value: [ + { + id: 'local_test', + libraryItemId: 'test34', + serverAddress: 'https://abs.test.com', + serverUserId: 'test56', + folderId: 'test1', + absolutePath: 'a', + contentUrl: 'c', + isInvalid: false, + mediaType: 'book', + media: { + metadata: { + 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, - tags: [], - audioFiles: [], - chapters: [], - tracks: [ + localFiles: [ { - index: 1, - startOffset: 0, - duration: 10000, - title: 'Track Title 1', + id: 'lf1', + filename: 'lf1.mp3', contentUrl: 'test', + absolutePath: 'test', + simplePath: '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: {} + size: 39048290 } - ] - }, - localFiles: [ - { - id: 'lf1', - filename: 'lf1.mp3', - contentUrl: 'test', - absolutePath: 'test', - simplePath: 'test', - mimeType: 'audio/mpeg', - size: 39048290 - } - ], - coverContentUrl: null, - coverAbsolutePath: null, - isLocal: true - }] + ], + coverContentUrl: null, + coverAbsolutePath: null, + isLocal: true + } + ] } } async getLocalLibraryItemsInFolder({ folderId }) { @@ -167,7 +189,7 @@ class AbsDatabaseWeb extends WebPlugin { return this.getLocalLibraryItems().then((data) => data.value[0]) } 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() { return { @@ -182,7 +204,7 @@ class AbsDatabaseWeb extends WebPlugin { isFinished: false, lastUpdate: 394089090, startedAt: 239048209, - finishedAt: null, + finishedAt: null // For local lib items from server to support server sync // var serverConnectionConfigId:String?, // var serverAddress:String?, @@ -240,7 +262,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -250,7 +272,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 22) // 22 mins ago + timestamp: Date.now() - 1000 * 60 * 22 // 22 mins ago }, { name: 'Pause', @@ -260,7 +282,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: false, 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', @@ -270,7 +292,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -280,7 +302,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -290,7 +312,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -300,7 +322,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: null, serverSyncMessage: null, - timestamp: Date.now() - (1000 * 60 * 60) // 1 hour ago + timestamp: Date.now() - 1000 * 60 * 60 // 1 hour ago }, { name: 'Stop', @@ -310,7 +332,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -320,7 +342,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: true, serverSyncSuccess: true, 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', @@ -330,7 +352,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: 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', @@ -340,7 +362,7 @@ class AbsDatabaseWeb extends WebPlugin { serverSyncAttempted: false, serverSyncSuccess: 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() }) -export { AbsDatabase } \ No newline at end of file +export { AbsDatabase } diff --git a/plugins/capacitor/index.js b/plugins/capacitor/index.js index 29b4ae87..1a0bf03f 100644 --- a/plugins/capacitor/index.js +++ b/plugins/capacitor/index.js @@ -2,9 +2,10 @@ import Vue from 'vue' import { AbsAudioPlayer } from './AbsAudioPlayer' import { AbsDownloader } from './AbsDownloader' import { AbsFileSystem } from './AbsFileSystem' +import { AbsDatabase } from './AbsDatabase' import { AbsLogger } from './AbsLogger' import { Capacitor } from '@capacitor/core' Vue.prototype.$platform = Capacitor.getPlatform() -export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger } +export { AbsAudioPlayer, AbsDownloader, AbsFileSystem, AbsLogger, AbsDatabase } diff --git a/plugins/db.js b/plugins/db.js index 8819434f..747e6e56 100644 --- a/plugins/db.js +++ b/plugins/db.js @@ -1,7 +1,7 @@ import { AbsDatabase } from './capacitor/AbsDatabase' class DbService { - constructor() { } + constructor() {} getDeviceData() { return AbsDatabase.getDeviceData().then((data) => { @@ -29,10 +29,12 @@ class DbService { } getLocalFolders() { - return AbsDatabase.getLocalFolders().then((data) => data.value).catch((error) => { - console.error('Failed to load', error) - return null - }) + return AbsDatabase.getLocalFolders() + .then((data) => data.value) + .catch((error) => { + console.error('Failed to load', error) + return null + }) } getLocalFolder(folderId) { @@ -103,4 +105,10 @@ class DbService { export default ({ app, store }, inject) => { inject('db', new DbService()) -} \ No newline at end of file + + // Listen for token refresh events from native app + AbsDatabase.addListener('onTokenRefresh', (data) => { + console.log('[db] onTokenRefresh', data) + store.commit('user/setAccessToken', data.accessToken) + }) +} diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js index f2586dc2..2d4bc908 100644 --- a/plugins/nativeHttp.js +++ b/plugins/nativeHttp.js @@ -1,8 +1,13 @@ import { CapacitorHttp } from '@capacitor/core' +import { AbsDatabase } from '@/plugins/capacitor' export default function ({ store }, inject) { 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 const headers = {} if (!url.startsWith('http') && !url.startsWith('capacitor')) { @@ -12,9 +17,8 @@ export default function ({ store }, inject) { } else { console.warn('[nativeHttp] No Bearer Token for request') } - const serverUrl = store.getters['user/getServerAddress'] - if (serverUrl) { - url = `${serverUrl}${url}` + if (serverConnectionConfig?.address) { + url = `${serverConnectionConfig.address}${url}` } } if (data) { @@ -27,7 +31,12 @@ export default function ({ store }, inject) { data, headers, ...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) { console.error(`[nativeHttp] ${res.status} status for url "${url}"`) throw new Error(res.data) @@ -35,6 +44,206 @@ export default function ({ store }, inject) { 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} - 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} - 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} - 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 = {}) { return this.request('GET', url, undefined, options) }, @@ -49,4 +258,4 @@ export default function ({ store }, inject) { } } inject('nativeHttp', nativeHttp) -} \ No newline at end of file +} diff --git a/store/user.js b/store/user.js index 78c2fcfe..120702ad 100644 --- a/store/user.js +++ b/store/user.js @@ -2,6 +2,7 @@ import { Browser } from '@capacitor/browser' export const state = () => ({ user: null, + accessToken: null, serverConnectionConfig: null, settings: { mobileOrderBy: 'addedAt', @@ -17,7 +18,7 @@ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getToken: (state) => { - return state.user?.token || null + return state.accessToken || null }, getServerConnectionConfigId: (state) => { return state.serverConnectionConfig?.id || null @@ -31,16 +32,18 @@ export const getters = { getCustomHeaders: (state) => { return state.serverConnectionConfig?.customHeaders || null }, - getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { - if (!state.user?.mediaProgress) return null - return state.user.mediaProgress.find(li => { - if (episodeId && li.episodeId !== episodeId) return false - return li.libraryItemId == libraryItemId - }) - }, + getUserMediaProgress: + (state) => + (libraryItemId, episodeId = null) => { + if (!state.user?.mediaProgress) return null + return state.user.mediaProgress.find((li) => { + if (episodeId && li.episodeId !== episodeId) return false + return li.libraryItemId == libraryItemId + }) + }, getUserBookmarksForItem: (state) => (libraryItemId) => { 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) => { return state.settings?.[key] || null @@ -143,13 +146,17 @@ export const mutations = { setUser(state, user) { state.user = user }, + setAccessToken(state, accessToken) { + console.log('[user] setAccessToken', accessToken) + state.accessToken = accessToken + }, removeMediaProgress(state, id) { 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) { 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) { state.user.mediaProgress.splice(mediaProgressIndex, 1, data) } else { @@ -174,9 +181,9 @@ export const mutations = { }, deleteBookmark(state, { libraryItemId, time }) { 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 return true }) } -} \ No newline at end of file +}