diff --git a/components/readers/ComicReader.vue b/components/readers/ComicReader.vue index bd7ed4e1..f62ea87d 100644 --- a/components/readers/ComicReader.vue +++ b/components/readers/ComicReader.vue @@ -77,9 +77,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -247,10 +244,7 @@ export default { // TODO: Handle JWT auth refresh const buff = await this.$axios.$get(this.url, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) const archive = await Archive.open(buff) diff --git a/components/readers/EpubReader.vue b/components/readers/EpubReader.vue index f40cf07c..28857a5d 100644 --- a/components/readers/EpubReader.vue +++ b/components/readers/EpubReader.vue @@ -49,9 +49,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, /** @returns {string} */ libraryItemId() { return this.libraryItem?.id @@ -298,15 +295,26 @@ export default { /** @type {EpubReader} */ const reader = this + + // Use axios to make request because we have token refresh logic in interceptor + const customRequest = async (url) => { + try { + return this.$axios.$get(url, { + responseType: 'arraybuffer' + }) + } catch (error) { + console.error('EpubReader.initEpub customRequest failed:', error) + throw error + } + } + console.log('[EpubReader] initEpub', reader.url) /** @type {ePub.Book} */ reader.book = new ePub(reader.url, { width: window.innerWidth, height: window.innerHeight - this.readerHeightOffset, openAs: 'epub', - requestHeaders: { - Authorization: `Bearer ${this.userToken}` - } + requestMethod: this.isLocal ? null : customRequest }) /** @type {ePub.Rendition} */ diff --git a/components/readers/MobiReader.vue b/components/readers/MobiReader.vue index a3d5c112..53de8ecd 100644 --- a/components/readers/MobiReader.vue +++ b/components/readers/MobiReader.vue @@ -22,11 +22,7 @@ export default { data() { return {} }, - computed: { - userToken() { - return this.$store.getters['user/getToken'] - } - }, + computed: {}, methods: { addHtmlCss() { let iframe = document.getElementsByTagName('iframe')[0] @@ -86,10 +82,7 @@ export default { // Fetch mobi file as blob // TODO: Handle JWT auth refresh var buff = await this.$axios.$get(this.url, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) var reader = new FileReader() reader.onload = async (event) => { @@ -134,4 +127,4 @@ export default { overflow-x: hidden; overflow-y: auto; } - \ No newline at end of file + diff --git a/components/readers/PdfReader.vue b/components/readers/PdfReader.vue index 8a163719..1446e396 100644 --- a/components/readers/PdfReader.vue +++ b/components/readers/PdfReader.vue @@ -49,7 +49,8 @@ export default { numPages: 0, windowWidth: 0, windowHeight: 0, - pdfDocInitParams: null + pdfDocInitParams: null, + isRefreshing: false } }, computed: { @@ -109,6 +110,10 @@ export default { }, isPlayerOpen() { return this.$store.getters['getIsPlayerOpen'] + }, + ebookUrl() { + const serverAddress = this.$store.getters['user/getServerAddress'] + return this.isLocal ? this.url : `${serverAddress}${this.url}` } }, methods: { @@ -164,7 +169,54 @@ export default { this.page++ this.updateProgress() }, - error(err) { + async handleRefreshFailure() { + try { + console.log('[PdfReader] Handling refresh failure - logging out user') + + // Clear store + await this.$store.dispatch('user/logout') + + if (this.$store.getters['user/getServerConnectionConfigId']) { + // Clear refresh token for server connection config + await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId']) + } + + if (window.location.pathname !== '/connect') { + window.location.href = '/connect' + } + } catch (error) { + console.error('[PdfReader] Failed to handle refresh failure:', error) + } + }, + async refreshToken() { + if (this.isRefreshing) return + this.isRefreshing = true + // Cannot use axios with this pdf reader so we need to handle the refresh separately + // Should work on migrating to a different pdf reader in the future + const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => { + console.error('Failed to refresh token', error) + return null + }) + if (!newAccessToken) { + this.handleRefreshFailure() + return + } + + // Force Vue to re-render the PDF component by creating a new object + this.pdfDocInitParams = { + url: this.ebookUrl, + httpHeaders: { + Authorization: `Bearer ${newAccessToken}` + } + } + this.isRefreshing = false + }, + async error(err) { + if (err && err.status === 401) { + console.log('Received 401 error, refreshing token') + await this.refreshToken() + return + } console.error(err) }, screenOrientationChange() { @@ -173,7 +225,7 @@ export default { }, init() { this.pdfDocInitParams = { - url: this.url, + url: this.ebookUrl, httpHeaders: { Authorization: `Bearer ${this.userToken}` } diff --git a/components/readers/Reader.vue b/components/readers/Reader.vue index dda49acb..aa76df67 100644 --- a/components/readers/Reader.vue +++ b/components/readers/Reader.vue @@ -305,11 +305,11 @@ export default { if (this.localContentUrl) { return Capacitor.convertFileSrc(this.localContentUrl) } - const serverAddress = this.$store.getters['user/getServerAddress'] + if (this.ebookFileId) { - return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}` + return `/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}` } - return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook` + return `/api/items/${this.selectedLibraryItem.id}/ebook` }, isPlayerOpen() { return this.$store.getters['getIsPlayerOpen'] diff --git a/plugins/axios.js b/plugins/axios.js index 5974874f..6424446d 100644 --- a/plugins/axios.js +++ b/plugins/axios.js @@ -1,17 +1,50 @@ -export default function ({ $axios, store }) { +export default function ({ $axios, store, $db }) { + // Track if we're currently refreshing to prevent multiple refresh attempts + let isRefreshing = false + let failedQueue = [] + + const processQueue = (error, token = null) => { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error) + } else { + resolve(token) + } + }) + failedQueue = [] + } + + /** + * Handles the case when token refresh fails + * @param {string} [serverConnectionConfigId] + * @returns {Promise} - Promise that resolves when logout is complete + */ + const handleRefreshFailure = async (serverConnectionConfigId) => { + try { + console.log('[axios] Handling refresh failure - logging out user') + + // Clear store + await store.dispatch('user/logout') + + if (serverConnectionConfigId) { + // Clear refresh token for server connection config + await $db.clearRefreshToken(serverConnectionConfigId) + } + + if (window.location.pathname !== '/connect') { + window.location.href = '/connect' + } + } catch (error) { + console.error('[axios] Failed to handle refresh failure:', error) + } + } + $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 } - const customHeaders = store.getters['user/getCustomHeaders'] - if (customHeaders) { - for (const key in customHeaders) { - config.headers.common[key] = customHeaders[key] - } - } - const bearerToken = store.getters['user/getToken'] if (bearerToken) { config.headers.common['Authorization'] = `Bearer ${bearerToken}` @@ -26,7 +59,75 @@ export default function ({ $axios, store }) { console.log('[Axios] Request out', config.url) }) - $axios.onError((error) => { - console.error('Axios error code', error) + $axios.onError(async (error) => { + const originalRequest = error.config + const code = parseInt(error.response && error.response.status) + const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' + + console.error('Axios error', code, message) + + // Handle 401 Unauthorized (token expired) + if (code === 401 && !originalRequest._retry) { + // Skip refresh for auth endpoints to prevent infinite loops + if (originalRequest.url.endsWith('/auth/refresh') || originalRequest.url.endsWith('/login')) { + await handleRefreshFailure(store.getters['user/getServerConnectionConfigId']) + return Promise.reject(error) + } + + if (isRefreshing) { + // If already refreshing, queue this request + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }) + }) + .then((token) => { + if (!originalRequest.headers) { + originalRequest.headers = {} + } + originalRequest.headers['Authorization'] = `Bearer ${token}` + return $axios(originalRequest) + }) + .catch((err) => { + return Promise.reject(err) + }) + } + + originalRequest._retry = true + isRefreshing = true + + try { + // Attempt to refresh the token + // Updates store if successful, otherwise clears store and throw error + const newAccessToken = await store.dispatch('user/refreshToken') + if (!newAccessToken) { + console.error('No new access token received') + return Promise.reject(error) + } + + // Update the original request with new token + if (!originalRequest.headers) { + originalRequest.headers = {} + } + originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}` + + // Process any queued requests + processQueue(null, newAccessToken) + + // Retry the original request + return $axios(originalRequest) + } catch (refreshError) { + console.error('Token refresh failed:', refreshError) + + // Process queued requests with error + processQueue(refreshError, null) + + await handleRefreshFailure(store.getters['user/getServerConnectionConfigId']) + + return Promise.reject(refreshError) + } finally { + isRefreshing = false + } + } + + return Promise.reject(error) }) } diff --git a/store/user.js b/store/user.js index 63161bb0..042eb55c 100644 --- a/store/user.js +++ b/store/user.js @@ -1,5 +1,6 @@ import { Browser } from '@capacitor/browser' import { AbsLogger } from '@/plugins/capacitor' +import { CapacitorHttp } from '@capacitor/core' export const state = () => ({ user: null, @@ -30,9 +31,6 @@ export const getters = { getServerConfigName: (state) => { return state.serverConnectionConfig?.name || null }, - getCustomHeaders: (state) => { - return state.serverConnectionConfig?.customHeaders || null - }, getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => { @@ -165,6 +163,61 @@ export const actions = { commit('logout') commit('libraries/setCurrentLibrary', null, { root: true }) await AbsLogger.info({ tag: 'user', message: `Logged out from server ${state.serverConnectionConfig?.name || 'Not connected'}` }) + }, + async refreshToken({ getters, commit, state }) { + const refreshToken = await this.$db.getRefreshToken(getters.getServerConnectionConfigId) + if (!refreshToken) { + console.error('No refresh token found') + return null + } + + const serverAddress = getters.getServerAddress + + const response = await CapacitorHttp.post({ + url: `${serverAddress}/auth/refresh`, + headers: { + 'Content-Type': 'application/json', + 'x-refresh-token': refreshToken + }, + data: {} + }) + + if (response.status !== 200) { + console.error('[user] Token refresh request failed:', response.status) + return null + } + + const userResponseData = response.data + if (!userResponseData.user?.accessToken) { + console.error('[user] No access token in refresh response') + return null + } + + // Update the config with new tokens + const updatedConfig = { + ...state.serverConnectionConfig, + token: userResponseData.user.accessToken, + refreshToken: userResponseData.user.refreshToken + } + + // Save updated config to secure storage, persists refresh token in secure storage + const savedConfig = await this.$db.setServerConnectionConfig(updatedConfig) + + // Update the store + commit('setAccessToken', userResponseData.user.accessToken) + + // Re-authenticate socket if necessary + if (this.$socket?.connected && !this.$socket.isAuthenticated) { + this.$socket.sendAuthenticate() + } else if (!this.$socket) { + console.warn('[user] Socket not available, cannot re-authenticate') + } + + if (savedConfig) { + commit('setServerConnectionConfig', savedConfig) + } + + return userResponseData.user.accessToken } }