diff --git a/src/main/features/core/settings/index.ts b/src/main/features/core/settings/index.ts index 654ac74a..47e6264b 100644 --- a/src/main/features/core/settings/index.ts +++ b/src/main/features/core/settings/index.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron'; +import { ipcMain, safeStorage } from 'electron'; import Store from 'electron-store'; export const store = new Store(); @@ -10,3 +10,41 @@ ipcMain.handle('settings-get', (_event, data: { property: string }) => { ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => { store.set(`${data.property}`, data.value); }); + +ipcMain.handle('password-get', (_event, server: string): string | null => { + if (safeStorage.isEncryptionAvailable()) { + const servers = store.get('server') as Record | undefined; + + if (!servers) { + return null; + } + + const encrypted = servers[server]; + if (!encrypted) return null; + + const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex')); + return decrypted; + } + + return null; +}); + +ipcMain.on('password-remove', (_event, server: string) => { + const passwords = store.get('server', {}) as Record; + if (server in passwords) { + delete passwords[server]; + } + store.set({ server: passwords }); +}); + +ipcMain.handle('password-set', (_event, password: string, server: string) => { + if (safeStorage.isEncryptionAvailable()) { + const encrypted = safeStorage.encryptString(password); + const passwords = store.get('server', {}) as Record; + passwords[server] = encrypted.toString('hex'); + store.set({ server: passwords }); + + return true; + } + return false; +}); diff --git a/src/main/preload/local-settings.ts b/src/main/preload/local-settings.ts index 33ff14da..fc08369e 100644 --- a/src/main/preload/local-settings.ts +++ b/src/main/preload/local-settings.ts @@ -23,6 +23,18 @@ const disableMediaKeys = () => { ipcRenderer.send('global-media-keys-disable'); }; +const passwordGet = async (server: string): Promise => { + return ipcRenderer.invoke('password-get', server); +}; + +const passwordRemove = (server: string) => { + ipcRenderer.send('password-remove', server); +}; + +const passwordSet = async (password: string, server: string): Promise => { + return ipcRenderer.invoke('password-set', password, server); +}; + const setZoomFactor = (zoomFactor: number) => { webFrame.setZoomFactor(zoomFactor / 100); }; @@ -31,6 +43,9 @@ export const localSettings = { disableMediaKeys, enableMediaKeys, get, + passwordGet, + passwordRemove, + passwordSet, restart, set, setZoomFactor, diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 57e3b602..fdcbe4a5 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -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); diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 9323e0e1..17df54c0 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -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 => { + 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: { diff --git a/src/renderer/api/utils.ts b/src/renderer/api/utils.ts index 2315969b..13871b40 100644 --- a/src/renderer/api/utils.ts +++ b/src/renderer/api/utils.ts @@ -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 = (itemSchema: ItemType) => { @@ -21,3 +24,17 @@ export const resultSubsonicBaseResponse = ( .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); + } +}; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index f252773b..a8d30b16 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -166,6 +166,10 @@ export const App = () => { { legacyAuth: false, name: '', password: '', + savePassword: false, type: ServerType.JELLYFIN, url: 'http://', username: '', @@ -83,6 +87,13 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { } else { toast.success({ message: 'Server has been added' }); } + + if (localSettings && values.savePassword) { + const saved = await localSettings.passwordSet(values.password, serverItem.id); + if (!saved) { + toast.error({ message: 'Could not save password' }); + } + } } catch (err: any) { setIsLoading(false); return toast.error({ message: err?.message }); @@ -120,6 +131,14 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { label="Password" {...form.getInputProps('password')} /> + {localSettings && form.values.type === ServerType.NAVIDROME && ( + + )} {form.values.type === ServerType.SUBSONIC && ( void; + password?: string; server: ServerListItem; } @@ -26,7 +30,7 @@ const ModifiedFieldIndicator = () => { ); }; -export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormProps) => { +export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditServerFormProps) => { const { updateServer } = useAuthStoreActions(); const focusTrapRef = useFocusTrap(); const [isLoading, setIsLoading] = useState(false); @@ -35,7 +39,8 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro initialValues: { legacyAuth: false, name: server?.name, - password: '', + password: password || '', + savePassword: server.savePassword || false, type: server?.type, url: server?.url, username: server?.username, @@ -43,6 +48,7 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro }); const isSubsonic = form.values.type === ServerType.SUBSONIC; + const isNavidrome = form.values.type === ServerType.NAVIDROME; const handleSubmit = form.onSubmit(async (values) => { const authFunction = api.controller.authenticate; @@ -71,6 +77,7 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro credential: data.credential, name: values.name, ndCredential: data.ndCredential, + savePassword: values.savePassword, type: values.type, url: values.url, userId: data.userId, @@ -79,6 +86,17 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro updateServer(server.id, serverItem); toast.success({ message: 'Server has been updated' }); + + if (localSettings) { + if (values.savePassword) { + const saved = await localSettings.passwordSet(values.password, server.id); + if (!saved) { + toast.error({ message: 'Could not save password' }); + } + } else { + localSettings.passwordRemove(server.id); + } + } } catch (err: any) { setIsLoading(false); return toast.error({ message: err?.message }); @@ -115,6 +133,14 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro label="Password" {...form.getInputProps('password')} /> + {localSettings && isNavidrome && ( + + )} {isSubsonic && ( { const [edit, editHandlers] = useDisclosure(false); + const [savedPassword, setSavedPassword] = useState(''); const { deleteServer } = useAuthStoreActions(); const handleDeleteServer = () => { deleteServer(server.id); + localSettings?.passwordRemove(server.name); }; + const handleEdit = useCallback(() => { + if (!edit && localSettings && server.savePassword) { + localSettings + .passwordGet(server.id) + .then((password: string | null) => { + if (password) { + setSavedPassword(password); + } else { + setSavedPassword(''); + } + editHandlers.open(); + return null; + }) + .catch((error: any) => { + console.error(error); + setSavedPassword(''); + editHandlers.open(); + }); + } else { + setSavedPassword(''); + editHandlers.open(); + } + }, [edit, editHandlers, server.id, server.savePassword]); + return ( { > {edit ? ( editHandlers.toggle()} /> @@ -50,7 +81,7 @@ export const ServerListItem = ({ server }: ServerListItemProps) => { leftIcon={} tooltip={{ label: 'Edit server details' }} variant="subtle" - onClick={() => editHandlers.toggle()} + onClick={() => handleEdit()} > Edit diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index 2bd027ef..4976e7aa 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -31,6 +31,7 @@ import { ServerListItem, ServerType } from '/@/renderer/types'; import packageJson from '../../../../../package.json'; const browser = isElectron() ? window.electron.browser : null; +const localSettings = isElectron() ? window.electron.localSettings : null; export const AppMenu = () => { const navigate = useNavigate(); @@ -45,11 +46,21 @@ export const AppMenu = () => { setCurrentServer(server); }; - const handleCredentialsModal = (server: ServerListItem) => { + const handleCredentialsModal = async (server: ServerListItem) => { + let password: string | undefined; + + try { + if (localSettings && server.savePassword) { + password = await localSettings.passwordGet(server.id); + } + } catch (error) { + console.error(error); + } openModal({ children: server && ( diff --git a/src/renderer/preload.d.ts b/src/renderer/preload.d.ts index 15aa4b18..e200d2bf 100644 --- a/src/renderer/preload.d.ts +++ b/src/renderer/preload.d.ts @@ -16,6 +16,9 @@ declare global { source: string, lyric: InternetProviderLyricResponse, ): void; + PASSWORD_GET(server: string): Promise; + PASSWORD_REMOVE(server: string): void; + PASSWORD_SET(password: string, server: string): Promise; PLAYER_AUTO_NEXT(data: PlayerData): void; PLAYER_CURRENT_TIME(): void; PLAYER_GET_TIME(): number | undefined; diff --git a/src/renderer/styles/global.scss b/src/renderer/styles/global.scss index 3af91fac..0e82f2bf 100644 --- a/src/renderer/styles/global.scss +++ b/src/renderer/styles/global.scss @@ -126,10 +126,6 @@ button { animation: fadeOut 0.2s forwards; } -.mantine-Modal-content { - overflow: hidden; -} - @font-face { font-family: 'Archivo'; src: url('../fonts/Archivo-VariableFont_wdth,wght.ttf') format('truetype-variations'); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index cb09d6fe..809dae64 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -51,6 +51,7 @@ export type ServerListItem = { id: string; name: string; ndCredential?: string; + savePassword?: boolean; type: ServerType; url: string; userId: string | null;