fix(logging): handle media server connection refused error/toast (#748)

* fix(logging): handle media server connection refused error/toast

Properly log as connection refused if the jellyfin/emby server is unreachable. Previously it used to
throw a credentials error which lead to a lot of confusion

* refactor(i8n): extract translation keys

* refactor(auth): error message for a more consistent format

* refactor(auth/errors): use custom error types and error codes instead of abusing error messages

* refactor(i8n): replace connection refused translation key with invalidurl

* fix(error): combine auth and api error class into a single one called network error

* fix(error): use the new network error and network error codes in auth/api

* refactor(error): rename NetworkError to ApiError
This commit is contained in:
Fallenbagel
2024-05-23 19:34:31 +05:00
committed by GitHub
parent 10082292e8
commit f486fb5e75
6 changed files with 127 additions and 51 deletions

View File

@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { ApiErrorCode } from '@server/constants/error';
import availabilitySync from '@server/lib/availabilitySync'; import availabilitySync from '@server/lib/availabilitySync';
import logger from '@server/logger'; import logger from '@server/logger';
import { ApiError } from '@server/types/error';
import type { AxiosInstance } from 'axios'; import type { AxiosInstance } from 'axios';
import axios from 'axios'; import axios from 'axios';
@@ -129,9 +131,33 @@ class JellyfinAPI {
Pw: Password, Pw: Password,
} }
); );
return account.data; return account.data;
} catch (e) { } catch (e) {
throw new Error('Unauthorized'); const status = e.response?.status;
const networkErrorCodes = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ENETDOWN',
'ENETUNREACH',
'EPIPE',
'ECONNABORTED',
'EPROTO',
'EHOSTDOWN',
'EAI_AGAIN',
'ERR_INVALID_URL',
]);
if (networkErrorCodes.has(e.code) || status === 404) {
throw new ApiError(status, ApiErrorCode.InvalidUrl);
}
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
} }
} }

View File

@@ -0,0 +1,5 @@
export enum ApiErrorCode {
InvalidUrl = 'INVALID_URL',
InvalidCredentials = 'INVALID_CREDENTIALS',
NotAdmin = 'NOT_ADMIN',
}

View File

@@ -1,5 +1,6 @@
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import PlexTvAPI from '@server/api/plextv'; import PlexTvAPI from '@server/api/plextv';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
@@ -9,6 +10,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth'; import { isAuthenticated } from '@server/middleware/auth';
import { ApiError } from '@server/types/error';
import * as EmailValidator from 'email-validator'; import * as EmailValidator from 'email-validator';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
@@ -278,7 +280,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
if (!user && !(await userRepository.count())) { if (!user && !(await userRepository.count())) {
// Check if user is admin on jellyfin // Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) { if (account.User.Policy.IsAdministrator === false) {
throw new Error('not_admin'); throw new ApiError(403, ApiErrorCode.NotAdmin);
} }
logger.info( logger.info(
@@ -412,43 +414,63 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
return res.status(200).json(user?.filter() ?? {}); return res.status(200).json(user?.filter() ?? {});
} catch (e) { } catch (e) {
if (e.message === 'Unauthorized') { switch (e.errorCode) {
logger.warn( case ApiErrorCode.InvalidUrl:
'Failed login attempt from user with incorrect Jellyfin credentials', logger.error(
{ `The provided ${
label: 'Auth', process.env.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin'
account: { } is invalid or the server is not reachable.`,
ip: req.ip, {
email: body.username, label: 'Auth',
password: '__REDACTED__', error: e.errorCode,
}, status: e.statusCode,
} hostname: body.hostname,
); }
return next({ );
status: 401, return next({
message: 'Unauthorized', status: e.statusCode,
}); message: e.errorCode,
} else if (e.message === 'not_admin') { });
return next({
status: 403, case ApiErrorCode.InvalidCredentials:
message: 'CREDENTIAL_ERROR_NOT_ADMIN', logger.warn(
}); 'Failed login attempt from user with incorrect Jellyfin credentials',
} else if (e.message === 'add_email') { {
return next({ label: 'Auth',
status: 406, account: {
message: 'CREDENTIAL_ERROR_ADD_EMAIL', ip: req.ip,
}); email: body.username,
} else if (e.message === 'select_server_type') { password: '__REDACTED__',
return next({ },
status: 406, }
message: 'CREDENTIAL_ERROR_NO_SERVER_TYPE', );
}); return next({
} else { status: e.statusCode,
logger.error(e.message, { label: 'Auth' }); message: e.errorCode,
return next({ });
status: 500,
message: 'Something went wrong.', case ApiErrorCode.NotAdmin:
}); logger.warn(
'Failed login attempt from user without admin permissions',
{
label: 'Auth',
account: {
ip: req.ip,
email: body.username,
},
}
);
return next({
status: e.statusCode,
message: e.errorCode,
});
default:
logger.error(e.message, { label: 'Auth' });
return next({
status: 500,
message: 'Something went wrong.',
});
} }
} }
}); });

9
server/types/error.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { ApiErrorCode } from '@server/constants/error';
export class ApiError extends Error {
constructor(public statusCode: number, public errorCode: ApiErrorCode) {
super();
this.name = 'apiError';
}
}

View File

@@ -2,6 +2,7 @@ import Button from '@app/components/Common/Button';
import Tooltip from '@app/components/Common/Tooltip'; import Tooltip from '@app/components/Common/Tooltip';
import useSettings from '@app/hooks/useSettings'; import useSettings from '@app/hooks/useSettings';
import { InformationCircleIcon } from '@heroicons/react/24/solid'; import { InformationCircleIcon } from '@heroicons/react/24/solid';
import { ApiErrorCode } from '@server/constants/error';
import axios from 'axios'; import axios from 'axios';
import { Field, Form, Formik } from 'formik'; import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config'; import getConfig from 'next/config';
@@ -26,6 +27,7 @@ const messages = defineMessages({
loginerror: 'Something went wrong while trying to sign in.', loginerror: 'Something went wrong while trying to sign in.',
adminerror: 'You must use an admin account to sign in.', adminerror: 'You must use an admin account to sign in.',
credentialerror: 'The username or password is incorrect.', credentialerror: 'The username or password is incorrect.',
invalidurlerror: 'Unable to connect to {mediaServerName} server.',
signingin: 'Signing in…', signingin: 'Signing in…',
signin: 'Sign In', signin: 'Sign In',
initialsigningin: 'Connecting…', initialsigningin: 'Connecting…',
@@ -91,14 +93,24 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
email: values.email, email: values.email,
}); });
} catch (e) { } catch (e) {
let errorMessage = null;
switch (e.response?.data?.message) {
case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror;
break;
case ApiErrorCode.InvalidCredentials:
errorMessage = messages.credentialerror;
break;
case ApiErrorCode.NotAdmin:
errorMessage = messages.adminerror;
break;
default:
errorMessage = messages.loginerror;
break;
}
toasts.addToast( toasts.addToast(
intl.formatMessage( intl.formatMessage(errorMessage, mediaServerFormatValues),
e.message == 'Request failed with status code 401'
? messages.credentialerror
: e.message == 'Request failed with status code 403'
? messages.adminerror
: messages.loginerror
),
{ {
autoDismiss: true, autoDismiss: true,
appearance: 'error', appearance: 'error',

View File

@@ -219,8 +219,9 @@
"components.Layout.VersionStatus.outofdate": "Out of Date", "components.Layout.VersionStatus.outofdate": "Out of Date",
"components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop", "components.Layout.VersionStatus.streamdevelop": "Jellyseerr Develop",
"components.Layout.VersionStatus.streamstable": "Jellyseerr Stable", "components.Layout.VersionStatus.streamstable": "Jellyseerr Stable",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.adminerror": "You must use an admin account to sign in.", "components.Login.adminerror": "You must use an admin account to sign in.",
"components.Login.invalidurlerror": "Unable to connect to {mediaServerName} server.",
"components.Login.credentialerror": "The username or password is incorrect.",
"components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.", "components.Login.description": "Since this is your first time logging into {applicationName}, you are required to add a valid email address.",
"components.Login.email": "Email Address", "components.Login.email": "Email Address",
"components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.", "components.Login.emailtooltip": "Address does not need to be associated with your {mediaServerName} instance.",
@@ -752,8 +753,8 @@
"components.Settings.SettingsAbout.overseerrinformation": "About Jellyseerr", "components.Settings.SettingsAbout.overseerrinformation": "About Jellyseerr",
"components.Settings.SettingsAbout.preferredmethod": "Preferred", "components.Settings.SettingsAbout.preferredmethod": "Preferred",
"components.Settings.SettingsAbout.runningDevelop": "You are running the <code>develop</code> branch of Jellyseerr, which is only recommended for those contributing to development or assisting with bleeding-edge testing.", "components.Settings.SettingsAbout.runningDevelop": "You are running the <code>develop</code> branch of Jellyseerr, which is only recommended for those contributing to development or assisting with bleeding-edge testing.",
"components.Settings.SettingsAbout.supportoverseerr": "Support Overseerr",
"components.Settings.SettingsAbout.supportjellyseerr": "Support Jellyseerr", "components.Settings.SettingsAbout.supportjellyseerr": "Support Jellyseerr",
"components.Settings.SettingsAbout.supportoverseerr": "Support Overseerr",
"components.Settings.SettingsAbout.timezone": "Time Zone", "components.Settings.SettingsAbout.timezone": "Time Zone",
"components.Settings.SettingsAbout.totalmedia": "Total Media", "components.Settings.SettingsAbout.totalmedia": "Total Media",
"components.Settings.SettingsAbout.totalrequests": "Total Requests", "components.Settings.SettingsAbout.totalrequests": "Total Requests",
@@ -938,17 +939,18 @@
"components.Settings.hostname": "Hostname or IP Address", "components.Settings.hostname": "Hostname or IP Address",
"components.Settings.internalUrl": "Internal URL", "components.Settings.internalUrl": "Internal URL",
"components.Settings.is4k": "4K", "components.Settings.is4k": "4K",
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
"components.Settings.jellyfinSettings": "{mediaServerName} Settings", "components.Settings.jellyfinSettings": "{mediaServerName} Settings",
"components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.", "components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.",
"components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.", "components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!", "components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
"components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",
"components.Settings.jellyfinSyncFailedGenericError": "Something went wrong while syncing libraries",
"components.Settings.jellyfinSyncFailedNoLibrariesFound": "No libraries were found",
"components.Settings.jellyfinlibraries": "{mediaServerName} Libraries", "components.Settings.jellyfinlibraries": "{mediaServerName} Libraries",
"components.Settings.jellyfinlibrariesDescription": "The libraries {mediaServerName} scans for titles. Click the button below if no libraries are listed.", "components.Settings.jellyfinlibrariesDescription": "The libraries {mediaServerName} scans for titles. Click the button below if no libraries are listed.",
"components.Settings.jellyfinsettings": "{mediaServerName} Settings", "components.Settings.jellyfinsettings": "{mediaServerName} Settings",
"components.Settings.jellyfinsettingsDescription": "Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.", "components.Settings.jellyfinsettingsDescription": "Configure the settings for your {mediaServerName} server. {mediaServerName} scans your {mediaServerName} libraries to see what content is available.",
"components.Settings.jellyfinSyncFailedNoLibrariesFound": "No libraries were found",
"components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",
"components.Settings.jellyfinSyncFailedGenericError": "Something went wrong while syncing libraries",
"components.Settings.librariesRemaining": "Libraries Remaining: {count}", "components.Settings.librariesRemaining": "Libraries Remaining: {count}",
"components.Settings.manualscan": "Manual Library Scan", "components.Settings.manualscan": "Manual Library Scan",
"components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Jellyseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Jellyseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!",