From 6d453b8bb86dbeb234e414fc119d00254412b227 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge Date: Wed, 11 Dec 2024 10:15:17 +0000 Subject: [PATCH] Uppy refresh token logic - Fix existing code so it use relevant api client instead of imsApi - refactor thumbnail view logic to use displayed images - remove upload error uppy event listener as it not needed as backend deals with image errors - create uppy uppyOnBeforeRequest function that set the auth headers - create uppyOnAfterResponse to deal with 403 errors --- src/api/api.tsx | 63 ++++++++++++++++++- src/common/images/imageGallery.component.tsx | 16 ++--- .../images/uploadImagesDialog.component.tsx | 19 +++--- 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/api/api.tsx b/src/api/api.tsx index a6a28b2ec..677552fda 100644 --- a/src/api/api.tsx +++ b/src/api/api.tsx @@ -3,6 +3,7 @@ import { MicroFrontendId } from '../app.types'; import { readSciGatewayToken } from '../parseTokens'; import { InventoryManagementSystemSettings, settings } from '../settings'; import { InvalidateTokenType } from '../state/actions/actions.types'; +import { tokenRefreshed } from '../state/scigateway.actions'; import { APIError } from './api.types'; // These are for ensuring refresh request is only sent once when multiple requests @@ -79,7 +80,7 @@ const createAuthenticatedClient = (props: { return new Promise((resolve, reject) => { failedAuthRequestQueue.push((shouldReject?: boolean) => { if (shouldReject) reject(error); - else resolve(imsApi(originalRequest)); + else resolve(apiClient(originalRequest)); }); }); } @@ -91,6 +92,66 @@ const createAuthenticatedClient = (props: { return apiClient; }; +export function uppyOnAfterResponse(xhr: XMLHttpRequest) { + if (xhr.status >= 400 && xhr.status < 600) { + const errorMessage: string = ( + JSON.parse(xhr.responseText) as APIError + ).detail.toLocaleLowerCase(); + + // Check if the token is invalid and needs refreshing + if ( + xhr.status === 403 && + errorMessage.includes('expired token') && + localStorage.getItem('scigateway:token') + ) { + // Prevent other requests from also attempting to refresh while waiting for + // SciGateway to refresh the token + if (!isFetchingAccessToken) { + isFetchingAccessToken = true; + + // Request SciGateway to refresh the token + document.dispatchEvent( + new CustomEvent(MicroFrontendId, { + detail: { + type: InvalidateTokenType, + }, + }) + ); + + // Create a new promise to wait for the token to be refreshed + const tokenRefreshedPromise = new Promise((resolve, reject) => { + const handler = (e: Event) => { + const action = (e as CustomEvent).detail; + if (tokenRefreshed.match(action)) { + document.removeEventListener(MicroFrontendId, handler); + isFetchingAccessToken = false; + resolve(); // Resolve the promise when the token is refreshed + } + }; + + const timeoutId = setTimeout(() => { + // If the token isn't refreshed within a reasonable timeframe, reject the promise + document.removeEventListener(MicroFrontendId, handler); + isFetchingAccessToken = false; + reject(); + }, 20 * 1000); // 20 seconds timeout + + document.addEventListener(MicroFrontendId, handler); + + // Cleanup timeout when resolved + handler.resolve = () => clearTimeout(timeoutId); + }); + + return tokenRefreshedPromise; + } + } + } +} + +export function uppyOnBeforeRequest(xhr: XMLHttpRequest) { + xhr.setRequestHeader('Authorization', `Bearer ${readSciGatewayToken()}`); +} + export const imsApi = createAuthenticatedClient({ getURL: (settings) => settings.imsApiUrl, }); diff --git a/src/common/images/imageGallery.component.tsx b/src/common/images/imageGallery.component.tsx index c2e3af3f7..3477e4709 100644 --- a/src/common/images/imageGallery.component.tsx +++ b/src/common/images/imageGallery.component.tsx @@ -243,10 +243,8 @@ const ImageGallery = (props: ImageGalleryProps) => { .getSortedRowModel() .rows.map((row) => row.getVisibleCells().map((cell) => cell)[0]); const displayedImages = table - .getPaginationRowModel() - .rows.map( - (row) => row.getVisibleCells().map((cell) => cell.row.original)[0] - ); + .getRowModel() + .rows.map((row) => row.getVisibleCells().map((cell) => cell)[0]); return ( <> @@ -320,18 +318,14 @@ const ImageGallery = (props: ImageGalleryProps) => { gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', }} > - {data.map((card, index) => { - const isUndisplayed = !displayedImages?.some( - (img) => img.id === card.row.original.id - ); - + {displayedImages.map((card, index) => { const lastPageIndex = Math.floor( - data.length / preservedState.pagination.pageSize + displayedImages.length / preservedState.pagination.pageSize ); const isLastPage = preservedState.pagination.pageIndex === lastPageIndex; - return isUndisplayed ? null : ( + return ( { endpoint: `${url}/images`, method: 'POST', fieldName: 'upload_file', + limit: 1, // Limit uploads to one file at a time + // Reason 1: To avoid overloading the memory of the object-store API. + // Reason 2: To prevent multiple simultaneous uploads from triggering + // the token refresh process multiple times, which could lead to race conditions. + async onBeforeRequest(xhr) { + uppyOnBeforeRequest(xhr); + }, + async onAfterResponse(xhr) { + await uppyOnAfterResponse(xhr); + }, }); }); @@ -86,14 +97,6 @@ const UploadImagesDialog = (props: UploadImagesDialogProps) => { } }); - uppy.on('upload-error', (_file, _error, response) => { - if (response?.body?.id) { - // TODO: Implement logic to delete metadata using id - // If metadata exists for the given id, remove it from the api - // If not, do nothing and exit the function - } - }); - return (