mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 02:13:33 +00:00
initial implementation for password saving (#132)
* initial implementation for password saving * support restoring password in interceptor * Fix modal overflow and position styles * warn about 429, better error handling --------- Co-authored-by: jeffvli <jeffvictorli@gmail.com> Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
This commit is contained in:
parent
a3a84766e4
commit
2fac9efc1b
13 changed files with 310 additions and 62 deletions
|
|
@ -3,10 +3,10 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
|
|||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
|
||||
import qs from 'qs';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import { z } from 'zod';
|
||||
import { authenticationFailure } from '/@/renderer/api/utils';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
|
@ -269,19 +269,9 @@ axiosClient.interceptors.response.use(
|
|||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (currentServer) {
|
||||
const serverId = currentServer.id;
|
||||
const token = currentServer.credential;
|
||||
console.log(`token is expired: ${token}`);
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { credential: undefined });
|
||||
}
|
||||
authenticationFailure(currentServer);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { initClient, initContract } from '@ts-rest/core';
|
||||
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
|
||||
import isElectron from 'is-electron';
|
||||
import { debounce } from 'lodash';
|
||||
import omitBy from 'lodash/omitBy';
|
||||
import qs from 'qs';
|
||||
import { ndType } from './navidrome-types';
|
||||
import { resultWithHeaders } from '/@/renderer/api/utils';
|
||||
import { toast } from '/@/renderer/components/toast/index';
|
||||
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
import { toast } from '/@/renderer/components';
|
||||
|
||||
const localSettings = isElectron() ? window.electron.localSettings : null;
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
|
@ -168,43 +172,6 @@ axiosClient.defaults.paramsSerializer = (params) => {
|
|||
return qs.stringify(params, { arrayFormat: 'repeat' });
|
||||
};
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
|
||||
|
||||
if (headerCredential) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: headerCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (currentServer) {
|
||||
const serverId = currentServer.id;
|
||||
const token = currentServer.ndCredential;
|
||||
console.log(`token is expired: ${token}`);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const parsePath = (fullPath: string) => {
|
||||
const [path, params] = fullPath.split('?');
|
||||
|
||||
|
|
@ -231,6 +198,134 @@ const parsePath = (fullPath: string) => {
|
|||
};
|
||||
};
|
||||
|
||||
let authSuccess = true;
|
||||
let shouldDelay = false;
|
||||
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
const waitForResult = async (count = 0): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (count === MAX_RETRIES || !shouldDelay) resolve();
|
||||
|
||||
setTimeout(() => {
|
||||
waitForResult(count + 1)
|
||||
.then(resolve)
|
||||
.catch(resolve);
|
||||
}, RETRY_DELAY_MS);
|
||||
});
|
||||
};
|
||||
|
||||
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
|
||||
const TIMEOUT_ERROR = Error();
|
||||
|
||||
axiosClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const serverId = useAuthStore.getState().currentServer?.id;
|
||||
|
||||
if (serverId) {
|
||||
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
|
||||
|
||||
if (headerCredential) {
|
||||
useAuthStore.getState().actions.updateServer(serverId, {
|
||||
ndCredential: headerCredential,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
authSuccess = true;
|
||||
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
const currentServer = useAuthStore.getState().currentServer;
|
||||
|
||||
if (localSettings && currentServer?.savePassword) {
|
||||
// eslint-disable-next-line promise/no-promise-in-callback
|
||||
return localSettings
|
||||
.passwordGet(currentServer.id)
|
||||
.then(async (password: string | null) => {
|
||||
authSuccess = false;
|
||||
|
||||
if (password === null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (shouldDelay) {
|
||||
await waitForResult();
|
||||
|
||||
// Hopefully the delay was sufficient for authentication.
|
||||
// Otherwise, it will require manual intervention
|
||||
if (authSuccess) {
|
||||
return axiosClient.request(error.config);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
shouldDelay = true;
|
||||
|
||||
// Do not use axiosClient. Instead, manually make a post
|
||||
const res = await axios.post(`${currentServer.url}/auth/login`, {
|
||||
password,
|
||||
username: currentServer.username,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
toast.error({
|
||||
message:
|
||||
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
|
||||
title: 'Your session has expired.',
|
||||
});
|
||||
|
||||
const serverId = currentServer.id;
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
|
||||
// special error to prevent sending a second message, and stop other messages that could be enqueued
|
||||
limitedFail.cancel();
|
||||
throw TIMEOUT_ERROR;
|
||||
}
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to authenticate');
|
||||
}
|
||||
|
||||
const newCredential = res.data.token;
|
||||
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
|
||||
|
||||
useAuthStore.getState().actions.updateServer(currentServer.id, {
|
||||
credential: subsonicCredential,
|
||||
ndCredential: newCredential,
|
||||
});
|
||||
|
||||
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
|
||||
|
||||
authSuccess = true;
|
||||
|
||||
return axiosClient.request(error.config);
|
||||
})
|
||||
.catch((newError: any) => {
|
||||
if (newError !== TIMEOUT_ERROR) {
|
||||
console.error('Error when trying to reauthenticate: ', newError);
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
// make sure to pass the error so axios will error later on
|
||||
throw newError;
|
||||
})
|
||||
.finally(() => {
|
||||
shouldDelay = false;
|
||||
});
|
||||
}
|
||||
|
||||
limitedFail(currentServer);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export const ndApiClient = (args: {
|
||||
server: ServerListItem | null;
|
||||
signal?: AbortSignal;
|
||||
|
|
@ -253,6 +348,8 @@ export const ndApiClient = (args: {
|
|||
}
|
||||
|
||||
try {
|
||||
if (shouldDelay) await waitForResult();
|
||||
|
||||
const result = await axiosClient.request({
|
||||
data: body,
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { AxiosHeaders } from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { toast } from '/@/renderer/components';
|
||||
import { useAuthStore } from '/@/renderer/store';
|
||||
import { ServerListItem } from '/@/renderer/types';
|
||||
|
||||
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
|
||||
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
|
||||
|
|
@ -21,3 +24,17 @@ export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
|
|||
.extend(itemSchema),
|
||||
});
|
||||
};
|
||||
|
||||
export const authenticationFailure = (currentServer: ServerListItem | null) => {
|
||||
toast.error({
|
||||
message: 'Your session has expired.',
|
||||
});
|
||||
|
||||
if (currentServer) {
|
||||
const serverId = currentServer.id;
|
||||
const token = currentServer.ndCredential;
|
||||
console.log(`token is expired: ${token}`);
|
||||
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
|
||||
useAuthStore.getState().actions.setCurrentServer(null);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue