Prevent sending requests that are known to fail
In case it's still unknown if auth is set up on the server, only the initial request to detect this should be sent. Since all other requests will also just fail, they should be blocked until the auth initialization is completed. While the access token is getting refreshed, no request will succeed since the access token is expired; thus, they should not be sent.
This commit is contained in:
18
src/App.tsx
18
src/App.tsx
@@ -298,16 +298,16 @@ export const App: React.FC = () => (
|
|||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<GlobalDialog />
|
<GlobalDialog />
|
||||||
|
|
||||||
<ServerUpdateChecker />
|
|
||||||
<WebUIUpdateChecker />
|
|
||||||
<InitialBackgroundRequests />
|
|
||||||
<BackgroundSubscriptions />
|
|
||||||
|
|
||||||
<ReactRouterSetter />
|
|
||||||
|
|
||||||
<CssBaseline enableColorScheme />
|
|
||||||
|
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
|
<ServerUpdateChecker />
|
||||||
|
<WebUIUpdateChecker />
|
||||||
|
<InitialBackgroundRequests />
|
||||||
|
<BackgroundSubscriptions />
|
||||||
|
|
||||||
|
<ReactRouterSetter />
|
||||||
|
|
||||||
|
<CssBaseline enableColorScheme />
|
||||||
|
|
||||||
<Box sx={{ display: 'flex' }}>
|
<Box sx={{ display: 'flex' }}>
|
||||||
<Box sx={{ flexShrink: 0, position: 'relative', height: '100vh' }}>
|
<Box sx={{ flexShrink: 0, position: 'relative', height: '100vh' }}>
|
||||||
<DefaultNavBar />
|
<DefaultNavBar />
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export class AuthManager {
|
|||||||
|
|
||||||
private static accessToken: string | null = null;
|
private static accessToken: string | null = null;
|
||||||
|
|
||||||
|
private static authInitialized: boolean = false;
|
||||||
|
|
||||||
|
private static refreshingToken: boolean = false;
|
||||||
|
|
||||||
static isAuthRequired(): boolean | null {
|
static isAuthRequired(): boolean | null {
|
||||||
return AppStorage.session.getItemParsed(AuthManager.AUTH_REQUIRED_KEY, null);
|
return AppStorage.session.getItemParsed(AuthManager.AUTH_REQUIRED_KEY, null);
|
||||||
}
|
}
|
||||||
@@ -90,4 +94,24 @@ export class AuthManager {
|
|||||||
static useListenToReactSessionContextRefreshEvent(): void {
|
static useListenToReactSessionContextRefreshEvent(): void {
|
||||||
useSessionStorage(AuthManager.REACT_SESSION_REFRESH_KEY, 0);
|
useSessionStorage(AuthManager.REACT_SESSION_REFRESH_KEY, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isAuthInitialized(): boolean {
|
||||||
|
return AuthManager.authInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
static setAuthInitialized(value: boolean): void {
|
||||||
|
AuthManager.authInitialized = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static isRefreshingToken(): boolean {
|
||||||
|
return AuthManager.refreshingToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
static setIsRefreshingToken(value: boolean): void {
|
||||||
|
AuthManager.refreshingToken = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static shouldQueueRequests(): boolean {
|
||||||
|
return !AuthManager.isAuthInitialized() || AuthManager.isRefreshingToken();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactNode, useEffect } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { useSessionContext } from '@/features/authentication/SessionContext.tsx';
|
import { useSessionContext } from '@/features/authentication/SessionContext.tsx';
|
||||||
import { SplashScreen } from '@/features/authentication/components/SplashScreen.tsx';
|
import { SplashScreen } from '@/features/authentication/components/SplashScreen.tsx';
|
||||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||||
@@ -17,17 +17,17 @@ export const AuthGuard = ({ children }: { children: ReactNode }) => {
|
|||||||
|
|
||||||
requestManager.useGetAbout({
|
requestManager.useGetAbout({
|
||||||
skip: isAuthRequired !== null,
|
skip: isAuthRequired !== null,
|
||||||
onCompleted: () => AuthManager.setAuthRequired(false),
|
onCompleted: () => {
|
||||||
|
if (AuthManager.isAuthInitialized()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthManager.setAuthRequired(false);
|
||||||
|
AuthManager.setAuthInitialized(true);
|
||||||
|
requestManager.processQueues();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onUnload = () => AuthManager.setAuthRequired(null);
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', onUnload);
|
|
||||||
|
|
||||||
return () => window.removeEventListener('beforeunload', onUnload);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (isAuthRequired === null) {
|
if (isAuthRequired === null) {
|
||||||
return <SplashScreen />;
|
return <SplashScreen />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { MaybeMasked, OperationVariables, Reference } from '@apollo/client/core'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { IRestClient, RestClient } from '@/lib/requests/client/RestClient.ts';
|
import { IRestClient, RestClient } from '@/lib/requests/client/RestClient.ts';
|
||||||
import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts';
|
import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts';
|
||||||
|
import { BaseClient } from '@/lib/requests/client/BaseClient.ts';
|
||||||
import {
|
import {
|
||||||
CategoryOrderBy,
|
CategoryOrderBy,
|
||||||
ChapterConditionInput,
|
ChapterConditionInput,
|
||||||
@@ -452,6 +453,12 @@ export class RequestManager {
|
|||||||
|
|
||||||
private readonly imageQueue = new Queue(5);
|
private readonly imageQueue = new Queue(5);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
BaseClient.setTokenRefreshCompleteCallback(() => {
|
||||||
|
this.processQueues();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public getClient(): IRestClient {
|
public getClient(): IRestClient {
|
||||||
return this.restClient;
|
return this.restClient;
|
||||||
}
|
}
|
||||||
@@ -463,6 +470,7 @@ export class RequestManager {
|
|||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
AuthManager.setAuthRequired(null);
|
AuthManager.setAuthRequired(null);
|
||||||
|
AuthManager.setAuthInitialized(false);
|
||||||
AuthManager.removeTokens();
|
AuthManager.removeTokens();
|
||||||
this.graphQLClient.client.resetStore();
|
this.graphQLClient.client.resetStore();
|
||||||
this.graphQLClient.terminateSubscriptions();
|
this.graphQLClient.terminateSubscriptions();
|
||||||
@@ -470,6 +478,11 @@ export class RequestManager {
|
|||||||
this.imageQueue.clear();
|
this.imageQueue.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public processQueues(): void {
|
||||||
|
this.graphQLClient.processQueue();
|
||||||
|
this.restClient.processQueue();
|
||||||
|
}
|
||||||
|
|
||||||
public getBaseUrl(): string {
|
public getBaseUrl(): string {
|
||||||
return this.restClient.getBaseUrl();
|
return this.restClient.getBaseUrl();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts';
|
|||||||
import { AuthManager } from '@/features/authentication/AuthManager.ts';
|
import { AuthManager } from '@/features/authentication/AuthManager.ts';
|
||||||
import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
|
import { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
|
||||||
import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts';
|
import { SubpathUtil } from '@/lib/utils/SubpathUtil.ts';
|
||||||
|
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
|
||||||
|
|
||||||
|
interface QueuedRequest {
|
||||||
|
execute: () => void;
|
||||||
|
resolve: (value: any) => void;
|
||||||
|
reject: (error: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
||||||
protected abstract client: Client;
|
protected abstract client: Client;
|
||||||
@@ -19,6 +26,14 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
|||||||
|
|
||||||
private static activeTokenRefreshPromise: Promise<UserRefreshMutation | null | undefined> | null = null;
|
private static activeTokenRefreshPromise: Promise<UserRefreshMutation | null | undefined> | null = null;
|
||||||
|
|
||||||
|
private static onTokenRefreshComplete: (() => void) | null = null;
|
||||||
|
|
||||||
|
protected requestQueue: QueuedRequest[] = [];
|
||||||
|
|
||||||
|
public static setTokenRefreshCompleteCallback(callback: (() => void) | null): void {
|
||||||
|
BaseClient.onTokenRefreshComplete = callback;
|
||||||
|
}
|
||||||
|
|
||||||
protected static async refreshAccessToken(
|
protected static async refreshAccessToken(
|
||||||
refreshFn: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>,
|
refreshFn: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>,
|
||||||
): Promise<UserRefreshMutation | null | undefined> {
|
): Promise<UserRefreshMutation | null | undefined> {
|
||||||
@@ -36,6 +51,8 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
|||||||
return this.activeTokenRefreshPromise;
|
return this.activeTokenRefreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuthManager.setIsRefreshingToken(true);
|
||||||
|
|
||||||
const refreshRequest = refreshFn(refreshToken).response;
|
const refreshRequest = refreshFn(refreshToken).response;
|
||||||
this.activeTokenRefreshPromise = refreshRequest.then((result) => result.data);
|
this.activeTokenRefreshPromise = refreshRequest.then((result) => result.data);
|
||||||
|
|
||||||
@@ -48,6 +65,9 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AuthManager.setAccessToken(data.refreshToken.accessToken);
|
AuthManager.setAccessToken(data.refreshToken.accessToken);
|
||||||
|
AuthManager.setAuthInitialized(true);
|
||||||
|
|
||||||
|
BaseClient.onTokenRefreshComplete?.();
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -55,6 +75,7 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
|||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
this.activeTokenRefreshPromise = null;
|
this.activeTokenRefreshPromise = null;
|
||||||
|
AuthManager.setIsRefreshingToken(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,5 +96,45 @@ export abstract class BaseClient<Client, ClientConfig, Fetcher> {
|
|||||||
return SubpathUtil.getApiBaseUrl(serverBaseURL);
|
return SubpathUtil.getApiBaseUrl(serverBaseURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
protected shouldQueueRequest(operationName?: string): boolean {
|
||||||
|
return AuthManager.shouldQueueRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected enqueueRequest<T>(executor: () => Promise<T>, operationName?: string): Promise<T> {
|
||||||
|
if (!this.shouldQueueRequest(operationName)) {
|
||||||
|
return executor();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { promise: requestPromise, reject, resolve } = new ControlledPromise<T>();
|
||||||
|
this.requestQueue.push({
|
||||||
|
execute: () => {
|
||||||
|
executor().then(resolve).catch(reject);
|
||||||
|
},
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
|
||||||
|
return requestPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
public processQueue(): void {
|
||||||
|
const queue = [...this.requestQueue];
|
||||||
|
this.requestQueue = [];
|
||||||
|
|
||||||
|
queue.forEach((request) => {
|
||||||
|
request.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected clearQueue(error?: Error): void {
|
||||||
|
const queue = [...this.requestQueue];
|
||||||
|
this.requestQueue = [];
|
||||||
|
|
||||||
|
queue.forEach((request) => {
|
||||||
|
request.reject(error ?? new Error('Request queue cleared'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public abstract updateConfig(config: Partial<ClientConfig>): void;
|
public abstract updateConfig(config: Partial<ClientConfig>): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
ApolloClient,
|
ApolloClient,
|
||||||
ApolloClientOptions,
|
ApolloClientOptions,
|
||||||
ApolloLink,
|
ApolloLink,
|
||||||
|
from,
|
||||||
|
fromPromise,
|
||||||
InMemoryCache,
|
InMemoryCache,
|
||||||
NormalizedCacheObject,
|
NormalizedCacheObject,
|
||||||
split,
|
split,
|
||||||
from,
|
toPromise,
|
||||||
fromPromise,
|
|
||||||
} from '@apollo/client';
|
} from '@apollo/client';
|
||||||
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
|
import createUploadLink from 'apollo-upload-client/createUploadLink.mjs';
|
||||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
|
||||||
@@ -210,6 +211,27 @@ export class GraphQLClient extends BaseClient<
|
|||||||
this.wsClient.terminate();
|
this.wsClient.terminate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override shouldQueueRequest(operationName: string | undefined): boolean {
|
||||||
|
const authOperations = ['GET_ABOUT', 'USER_LOGIN', 'USER_REFRESH'];
|
||||||
|
if (authOperations.includes(operationName!)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.shouldQueueRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createAuthGuardLink() {
|
||||||
|
return new ApolloLink((operation, forward) => {
|
||||||
|
const { operationName } = operation;
|
||||||
|
|
||||||
|
if (this.shouldQueueRequest(operationName)) {
|
||||||
|
return fromPromise(this.enqueueRequest(() => toPromise(forward(operation)), operationName));
|
||||||
|
}
|
||||||
|
|
||||||
|
return forward(operation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private createErrorLink() {
|
private createErrorLink() {
|
||||||
return onError(({ graphQLErrors, operation, forward }) => {
|
return onError(({ graphQLErrors, operation, forward }) => {
|
||||||
if (!graphQLErrors) {
|
if (!graphQLErrors) {
|
||||||
@@ -262,6 +284,7 @@ export class GraphQLClient extends BaseClient<
|
|||||||
},
|
},
|
||||||
this.createWSLink(),
|
this.createWSLink(),
|
||||||
from([
|
from([
|
||||||
|
this.createAuthGuardLink(),
|
||||||
this.createErrorLink(),
|
this.createErrorLink(),
|
||||||
this.createAuthLink(),
|
this.createAuthLink(),
|
||||||
removeTypenameLink,
|
removeTypenameLink,
|
||||||
|
|||||||
@@ -49,56 +49,57 @@ export class RestClient
|
|||||||
config?: RequestInit;
|
config?: RequestInit;
|
||||||
checkResponseIsJson?: boolean;
|
checkResponseIsJson?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<Response> => {
|
): Promise<Response> =>
|
||||||
const updatedUrl = url.startsWith('http') ? url : `${this.getBaseUrl()}${url}`;
|
this.enqueueRequest(async () => {
|
||||||
const isAuthRequired = AuthManager.isAuthRequired();
|
const updatedUrl = url.startsWith('http') ? url : `${this.getBaseUrl()}${url}`;
|
||||||
const accessToken = AuthManager.getAccessToken();
|
const isAuthRequired = AuthManager.isAuthRequired();
|
||||||
|
const accessToken = AuthManager.getAccessToken();
|
||||||
|
|
||||||
let result: Response;
|
let result: Response;
|
||||||
|
|
||||||
switch (httpMethod) {
|
switch (httpMethod) {
|
||||||
case HttpMethod.GET:
|
case HttpMethod.GET:
|
||||||
result = await this.client(updatedUrl, {
|
result = await this.client(updatedUrl, {
|
||||||
...this.config,
|
...this.config,
|
||||||
...config,
|
...config,
|
||||||
method: httpMethod,
|
method: httpMethod,
|
||||||
headers: {
|
headers: {
|
||||||
|
...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
...this.config.headers,
|
||||||
|
...config?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case HttpMethod.POST:
|
||||||
|
case HttpMethod.PATCH:
|
||||||
|
case HttpMethod.DELETE:
|
||||||
|
result = await this.client(updatedUrl, {
|
||||||
...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
...this.config.headers,
|
...this.config,
|
||||||
...config?.headers,
|
...config,
|
||||||
},
|
method: httpMethod,
|
||||||
});
|
body: JSON.stringify(data),
|
||||||
break;
|
});
|
||||||
case HttpMethod.POST:
|
break;
|
||||||
case HttpMethod.PATCH:
|
default:
|
||||||
case HttpMethod.DELETE:
|
throw new Error(`Unexpected HttpMethod "${httpMethod}"`);
|
||||||
result = await this.client(updatedUrl, {
|
}
|
||||||
...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
...this.config,
|
|
||||||
...config,
|
|
||||||
method: httpMethod,
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error(`Unexpected HttpMethod "${httpMethod}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status === 401) {
|
if (result.status === 401) {
|
||||||
await BaseClient.refreshAccessToken(this.handleRefreshToken);
|
await BaseClient.refreshAccessToken(this.handleRefreshToken);
|
||||||
return this.fetcher(url, { data, httpMethod, config, checkResponseIsJson });
|
return this.fetcher(url, { data, httpMethod, config, checkResponseIsJson });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status !== 200) {
|
if (result.status !== 200) {
|
||||||
throw new Error(`status ${result.status}: ${result.statusText}`);
|
throw new Error(`status ${result.status}: ${result.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkResponseIsJson && result.headers.get('content-type') !== 'application/json') {
|
if (checkResponseIsJson && result.headers.get('content-type') !== 'application/json') {
|
||||||
throw new Error('Response is not json');
|
throw new Error('Response is not json');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
});
|
||||||
|
|
||||||
constructor(handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>) {
|
constructor(handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>) {
|
||||||
super(handleRefreshToken);
|
super(handleRefreshToken);
|
||||||
|
|||||||
Reference in New Issue
Block a user