diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index c4d57eeea..58b5e0a16 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -586,6 +586,20 @@ export const controller: GeneralController = { server.type, )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); }, + getUserInfo(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getUserInfo`, + ); + } + + return apiController( + 'getUserInfo', + server.type, + )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + }, getUserList(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index 6ae6d85b3..aa525e8b3 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -247,6 +247,14 @@ export const contract = c.router({ 400: jfType._response.error, }, }, + getUser: { + method: 'GET', + path: 'users/:id', + responses: { + 200: jfType._response.user, + 400: jfType._response.error, + }, + }, movePlaylistItem: { body: null, method: 'POST', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index c15f261c1..203443db5 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1127,6 +1127,25 @@ export const JellyfinController: InternalControllerEndpoint = { totalRecordCount: res.body.TotalRecordCount, }; }, + getUserInfo: async (args) => { + const { apiClientProps, query } = args; + + const res = await jfApiClient(apiClientProps).getUser({ + params: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get user info'); + } + + return { + id: res.body.Id, + isAdmin: Boolean(res.body.Policy.IsAdministrator), + name: res.body.Name, + }; + }, movePlaylistItem: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 6979edbf7..c4c38d204 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -699,6 +699,7 @@ export const NavidromeController: InternalControllerEndpoint = { }; }, getTopSongs: SubsonicController.getTopSongs, + getUserInfo: SubsonicController.getUserInfo, getUserList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 37f7c1ff0..78fad3e13 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -204,6 +204,14 @@ export const contract = c.router({ 200: ssType._response.topSongsList, }, }, + getUser: { + method: 'GET', + path: 'getUser.view', + query: ssType._parameters.user, + responses: { + 200: ssType._response.user, + }, + }, ping: { method: 'GET', path: 'ping.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index fa260806f..2a723f698 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -144,7 +144,7 @@ export const SubsonicController: InternalControllerEndpoint = { return { credential, - isAdmin: resp.body.user.adminRoles, + isAdmin: Boolean(resp.body.user.adminRole), userId: resp.body.user.username, username: body.username, }; @@ -1472,6 +1472,25 @@ export const SubsonicController: InternalControllerEndpoint = { totalRecordCount: res.body.topSongs?.song?.length || 0, }; }, + getUserInfo: async (args) => { + const { apiClientProps, query } = args; + + const res = await ssApiClient(apiClientProps).getUser({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get user info'); + } + + return { + id: res.body.user.username, + isAdmin: Boolean(res.body.user.adminRole), + name: res.body.user.username, + }; + }, removeFromPlaylist: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { @@ -1583,7 +1602,6 @@ export const SubsonicController: InternalControllerEndpoint = { return null; }, - search: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/hooks/use-server-authenticated.ts b/src/renderer/hooks/use-server-authenticated.ts index 498390b2a..bd2314cfd 100644 --- a/src/renderer/hooks/use-server-authenticated.ts +++ b/src/renderer/hooks/use-server-authenticated.ts @@ -4,75 +4,217 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '/@/renderer/api'; import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; +import { LogCategory, logFn } from '/@/renderer/utils/logger'; +import { logMsg } from '/@/renderer/utils/logger-message'; import { toast } from '/@/shared/components/toast/toast'; -import { SongListSort, SortOrder } from '/@/shared/types/domain-types'; -import { AuthState, ServerListItem, ServerType } from '/@/shared/types/types'; +import { AuthState } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; export const useServerAuthenticated = () => { const priorServerId = useRef(undefined); const server = useCurrentServer(); - const [ready, setReady] = useState( - server?.type === ServerType.NAVIDROME ? AuthState.VALID : AuthState.VALID, - ); + const [ready, setReady] = useState(AuthState.VALID); - const { updateServer } = useAuthStoreActions(); + const { setCurrentServer, updateServer } = useAuthStoreActions(); - const authenticateNavidrome = useCallback( - async (server: ServerListItem) => { - // This trick works because navidrome-api.ts will internally check for authentication - // failures and try to log in again (where available). So, all that's necessary is - // making one request first + const authenticateServer = useCallback( + async (serverWithAuth: NonNullable>) => { try { - await api.controller.getSongList({ - apiClientProps: { serverId: server?.id || '' }, - query: { - limit: 1, - sortBy: SongListSort.NAME, - sortOrder: SortOrder.ASC, - startIndex: 0, + setReady(AuthState.LOADING); + + // Use userId if available, otherwise fall back to username (for Subsonic/Navidrome) + const userId = serverWithAuth.userId || serverWithAuth.username; + + if (!userId) { + throw new Error('No user ID or username available'); + } + + // First, try getUserInfo to check if current credentials are still valid + logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, { + category: LogCategory.SYSTEM, + meta: { + method: 'getUserInfo', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, }, }); - setReady(AuthState.VALID); + try { + const userInfo = await api.controller.getUserInfo({ + apiClientProps: { + serverId: serverWithAuth.id, + }, + query: { + id: userId, + }, + }); + + if (!userInfo) { + throw new Error('Failed to get user info'); + } + + // Update server with user info (in case isAdmin changed) + updateServer(serverWithAuth.id, { + isAdmin: userInfo.isAdmin, + }); + + logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { + category: LogCategory.SYSTEM, + meta: { + isAdmin: userInfo.isAdmin, + method: 'getUserInfo', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + userId: userInfo.id, + }, + }); + + setReady(AuthState.VALID); + return; + } catch (getUserInfoError: any) { + // Check if it's a forbidden/authentication error (401 or 403) + const isForbiddenError = + getUserInfoError?.response?.status === 401 || + getUserInfoError?.response?.status === 403 || + getUserInfoError?.message?.toLowerCase().includes('forbidden') || + getUserInfoError?.message?.toLowerCase().includes('unauthorized'); + + // Only reauthenticate if it's a forbidden error AND password is saved + if (isForbiddenError && serverWithAuth.savePassword && localSettings) { + const password = await localSettings.passwordGet(serverWithAuth.id); + + if (password) { + logFn.info(logMsg[LogCategory.SYSTEM].authenticatingServer, { + category: LogCategory.SYSTEM, + meta: { + method: 'authenticate', + reason: 'getUserInfo failed with forbidden error', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + url: serverWithAuth.url, + }, + }); + + // Authenticate using the API controller + const authData = await api.controller.authenticate( + serverWithAuth.url, + { + legacy: false, + password, + username: serverWithAuth.username, + }, + serverWithAuth.type, + ); + + if (!authData) { + throw new Error('Authentication failed: No data returned'); + } + + // Update server with new credentials + const updatedServer = { + credential: authData.credential, + isAdmin: authData.isAdmin, + userId: authData.userId, + username: authData.username, + ...(authData.ndCredential !== undefined && { + ndCredential: authData.ndCredential, + }), + }; + + updateServer(serverWithAuth.id, updatedServer); + + logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { + category: LogCategory.SYSTEM, + meta: { + isAdmin: authData.isAdmin, + method: 'authenticate', + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + userId: authData.userId, + username: authData.username, + }, + }); + + setReady(AuthState.VALID); + return; + } + } + + // If not a forbidden error, or no password saved, rethrow the error + throw getUserInfoError; + } } catch (error) { - // Clear server credentials (and saved password). - if (server.savePassword && localSettings) { - localSettings.passwordRemove(server.id); + const errorMessage = (error as Error).message || 'Authentication failed'; + + logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { + category: LogCategory.SYSTEM, + meta: { + error: errorMessage, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + }, + }); + + // Clear server credentials and saved password on failure + if (serverWithAuth.savePassword && localSettings) { + localSettings.passwordRemove(serverWithAuth.id); } - server.credential = ''; - updateServer(server.id, server); - - toast.error({ message: (error as Error).message }); + toast.error({ + message: errorMessage, + }); + // Log the user out by setting current server to null + setCurrentServer(null); setReady(AuthState.INVALID); } }, - [updateServer], + [updateServer, setCurrentServer], ); - const debouncedAuth = debounce((server: ServerListItem) => { - authenticateNavidrome(server).catch(console.error); - }, 300); + const debouncedAuth = debounce( + (serverWithAuth: NonNullable>) => { + authenticateServer(serverWithAuth).catch(console.error); + }, + 300, + ); useEffect(() => { if (!server) { + logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, { + category: LogCategory.SYSTEM, + meta: { + reason: 'No server selected', + }, + }); setReady(AuthState.INVALID); return; } - if (priorServerId.current !== server?.id) { - const serverWithAuth = getServerById(server!.id); - priorServerId.current = server?.id || ''; + if (priorServerId.current !== server.id) { + const serverWithAuth = getServerById(server.id); + priorServerId.current = server.id; - if (server?.type === ServerType.NAVIDROME) { - setReady(AuthState.LOADING); - debouncedAuth(serverWithAuth!); - } else { - setReady(AuthState.VALID); + if (!serverWithAuth) { + logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationError, { + category: LogCategory.SYSTEM, + meta: { + reason: 'Server not found in store', + serverId: server.id, + }, + }); + setReady(AuthState.INVALID); + return; } + + setReady(AuthState.LOADING); + debouncedAuth(serverWithAuth); } }, [debouncedAuth, server]); diff --git a/src/renderer/utils/logger-message.ts b/src/renderer/utils/logger-message.ts index 39d1067e3..b516a3412 100644 --- a/src/renderer/utils/logger-message.ts +++ b/src/renderer/utils/logger-message.ts @@ -66,5 +66,12 @@ export const logMsg = { scrobbledTimeupdate: 'Scrobbled a timeupdate event', scrobbledUnpause: 'Scrobbled an unpause event', }, - [LogCategory.SYSTEM]: {}, + [LogCategory.SYSTEM]: { + authenticatingServer: 'Authenticating server', + serverAuthenticationAborted: 'Server authentication aborted', + serverAuthenticationError: 'Server authentication error', + serverAuthenticationFailed: 'Server authentication failed', + serverAuthenticationInvalid: 'Server authentication invalid', + serverAuthenticationSuccess: 'Server authentication successful', + }, }; diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index b623e16b5..b79496f9a 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -7,9 +7,13 @@ const baseResponse = z.object({ }), }); -const authenticate = z.object({ +const userParameters = z.object({ + id: z.string(), +}); + +const user = z.object({ user: z.object({ - adminRoles: z.boolean(), + adminRole: z.boolean(), commentRole: z.boolean(), coverArtRole: z.boolean(), downloadRole: z.boolean(), @@ -26,6 +30,8 @@ const authenticate = z.object({ }), }); +const authenticate = user; + const authenticateParameters = z.object({ c: z.string(), f: z.string(), @@ -641,6 +647,7 @@ export const ssType = { structuredLyrics: structuredLyricsParameters, topSongsList: topSongsListParameters, updatePlaylist: updatePlaylistParameters, + user: userParameters, }, _response: { album, @@ -683,5 +690,6 @@ export const ssType = { song, structuredLyrics, topSongsList, + user, }, }; diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 87b9882bb..1dd39752d 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1276,7 +1276,6 @@ export type ControllerEndpoint = { getAlbumInfo?: (args: AlbumDetailArgs) => Promise; getAlbumList: (args: AlbumListArgs) => Promise; getAlbumListCount: (args: AlbumListCountArgs) => Promise; - // getArtistInfo?: (args: any) => void; getArtistList: (args: ArtistListArgs) => Promise; getArtistListCount: (args: ArtistListCountArgs) => Promise; getDownloadUrl: (args: DownloadArgs) => string; @@ -1299,6 +1298,8 @@ export type ControllerEndpoint = { getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise; getTagList?: (args: TagListArgs) => Promise; getTopSongs: (args: TopSongListArgs) => Promise; + // getArtistInfo?: (args: any) => void; + getUserInfo: (args: UserInfoArgs) => Promise; getUserList?: (args: UserListArgs) => Promise; movePlaylistItem?: (args: MoveItemArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; @@ -1393,6 +1394,7 @@ export type InternalControllerEndpoint = { ) => Promise; getTagList?: (args: ReplaceApiClientProps) => Promise; getTopSongs: (args: ReplaceApiClientProps) => Promise; + getUserInfo: (args: ReplaceApiClientProps) => Promise; getUserList?: (args: ReplaceApiClientProps) => Promise; movePlaylistItem?: (args: ReplaceApiClientProps) => Promise; removeFromPlaylist: ( @@ -1509,6 +1511,18 @@ export type TagListResponse = { }; }; +export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery }; + +export type UserInfoQuery = { + id: string; +}; + +export type UserInfoResponse = { + id: string; + isAdmin: boolean; + name: string; +}; + type BaseEndpointArgsWithServer = { apiClientProps: { server: null | ServerListItemWithCredential;