From 4e48fdf2cb9f76ae5c25073b585718650abd3288 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 17 Oct 2024 15:24:15 +0200 Subject: [PATCH] fix: rewrite avatarproxy and CachedImage (#1016) * fix: rewrite avatarproxy and CachedImage Avatar proxy was allowing every request to be proxied, no matter the original ressource's origin or filetype. This PR fixes it be allowing only relevant resources to be cached, i.e. Jellyfin/Emby images and TMDB images. fix #1012, #1013 * fix: resolve CodeQL error * fix: resolve CodeQL error * fix: resolve review comments * fix: resolve review comment * fix: resolve CodeQL error * fix: update imageproxy path --- server/routes/auth.ts | 15 +++------ server/routes/avatarproxy.ts | 23 +++++++++++-- server/routes/settings/index.ts | 7 +--- server/routes/user/index.ts | 8 +---- src/components/Blacklist/index.tsx | 3 ++ src/components/CollectionDetails/index.tsx | 2 ++ src/components/Common/CachedImage/index.tsx | 32 ++++++++++++------- src/components/Common/ImageFader/index.tsx | 1 + src/components/Common/Modal/index.tsx | 1 + src/components/CompanyCard/index.tsx | 1 + src/components/GenreCard/index.tsx | 1 + .../IssueDetails/IssueComment/index.tsx | 3 +- src/components/IssueDetails/index.tsx | 5 ++- src/components/IssueList/IssueItem/index.tsx | 5 ++- src/components/Layout/UserDropdown/index.tsx | 2 ++ src/components/ManageSlideOver/index.tsx | 2 ++ src/components/MovieDetails/index.tsx | 3 ++ src/components/PersonCard/index.tsx | 1 + src/components/PersonDetails/index.tsx | 1 + src/components/RequestCard/index.tsx | 4 +++ .../RequestList/RequestItem/index.tsx | 6 ++++ .../RequestModal/AdvancedRequester/index.tsx | 2 ++ .../RequestModal/CollectionRequestModal.tsx | 1 + src/components/Selector/index.tsx | 2 ++ src/components/TitleCard/index.tsx | 1 + src/components/TvDetails/index.tsx | 2 ++ .../UserList/JellyfinImportModal.tsx | 1 + src/components/UserList/index.tsx | 1 + .../UserProfile/ProfileHeader/index.tsx | 1 + 29 files changed, 97 insertions(+), 40 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 4e7f77278..560f04d57 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -262,8 +262,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { urlBase: body.urlBase, }); - const { externalHostname } = getSettings().jellyfin; - // Try to find deviceId that corresponds to jellyfin user, else generate a new one let user = await userRepository.findOne({ where: { jellyfinUsername: body.username }, @@ -281,11 +279,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { // First we need to attempt to log the user in to jellyfin const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId); - const jellyfinHost = - externalHostname && externalHostname.length > 0 - ? externalHostname - : hostname; - const ip = req.ip; let clientIp; @@ -336,7 +329,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` : gravatarUrl(body.email || account.User.Name, { default: 'mm', size: 200, @@ -355,7 +348,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` : gravatarUrl(body.email || account.User.Name, { default: 'mm', size: 200, @@ -410,7 +403,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ); // Update the users avatar with their jellyfin profile pic (incase it changed) if (account.User.PrimaryImageTag) { - const avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; + const avatar = `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; if (avatar !== user.avatar) { const avatarProxy = new ImageProxy('avatar', ''); avatarProxy.clearCachedImage(user.avatar); @@ -467,7 +460,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, avatar: account.User.PrimaryImageTag - ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` + ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` : gravatarUrl(body.email || account.User.Name, { default: 'mm', size: 200, diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index 65638df2b..e6f6f3b54 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -1,5 +1,8 @@ +import { MediaServerType } from '@server/constants/server'; import ImageProxy from '@server/lib/imageproxy'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; const router = Router(); @@ -7,9 +10,25 @@ const router = Router(); const avatarImageProxy = new ImageProxy('avatar', ''); // Proxy avatar images router.get('/*', async (req, res) => { - const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url; - + let imagePath = ''; try { + const jellyfinAvatar = req.url.match( + /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ + )?.[1]; + if (!jellyfinAvatar) { + const mediaServerType = getSettings().main.mediaServerType; + throw new Error( + `Provided URL is not ${ + mediaServerType === MediaServerType.JELLYFIN + ? 'a Jellyfin' + : 'an Emby' + } avatar.` + ); + } + + const imageUrl = new URL(jellyfinAvatar, getHostname()); + imagePath = imageUrl.toString(); + const imageData = await avatarImageProxy.getImage(imagePath); res.writeHead(200, { diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 30c854af9..3d6b6b0d3 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -377,11 +377,6 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { settingsRoutes.get('/jellyfin/users', async (req, res) => { const settings = getSettings(); - const { externalHostname } = settings.jellyfin; - const jellyfinHost = - externalHostname && externalHostname.length > 0 - ? externalHostname - : getHostname(); const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ @@ -401,7 +396,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { username: user.Name, id: user.Id, thumb: user.PrimaryImageTag - ? `${jellyfinHost}/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` + ? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` : gravatarUrl(user.Name, { default: 'mm', size: 200 }), email: user.Name, })); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index f8a0d41a2..83ad0910b 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -516,12 +516,6 @@ router.post( //const jellyfinUsersResponse = await jellyfinClient.getUsers(); const createdUsers: User[] = []; - const { externalHostname } = getSettings().jellyfin; - - const jellyfinHost = - externalHostname && externalHostname.length > 0 - ? externalHostname - : hostname; jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const jellyfinUsers = await jellyfinClient.getUsers(); @@ -546,7 +540,7 @@ router.post( email: jellyfinUser?.Name, permissions: settings.main.defaultPermissions, avatar: jellyfinUser?.PrimaryImageTag - ? `${jellyfinHost}/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` + ? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` : gravatarUrl(jellyfinUser?.Name ?? '', { default: 'mm', size: 200, diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx index 217f4cefd..a752e95f8 100644 --- a/src/components/Blacklist/index.tsx +++ b/src/components/Blacklist/index.tsx @@ -268,6 +268,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { {title && title.backdropPath && (
{ className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" > { { {data.backdropPath && (
{
src; +export type CachedImageProps = ImageProps & { + src: string; + type: 'tmdb' | 'avatar'; +}; + /** * The CachedImage component should be used wherever * we want to offer the option to locally cache images. **/ -const CachedImage = ({ src, ...props }: ImageProps) => { +const CachedImage = ({ src, type, ...props }: CachedImageProps) => { const { currentSettings } = useSettings(); - let imageUrl = src; - - if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { - const parsedUrl = new URL(imageUrl); + let imageUrl: string; - if (parsedUrl.host === 'image.tmdb.org') { - if (currentSettings.cacheImages) - imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); - } else if (parsedUrl.host !== 'gravatar.com') { - imageUrl = '/avatarproxy/' + imageUrl; - } + if (type === 'tmdb') { + // tmdb stuff + imageUrl = + currentSettings.cacheImages && !src.startsWith('/') + ? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/') + : src; + } else if (type === 'avatar') { + // jellyfin avatar (in any) + const jellyfinAvatar = src.match( + /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ + )?.[1]; + imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src; + } else { + return null; } return ; diff --git a/src/components/Common/ImageFader/index.tsx b/src/components/Common/ImageFader/index.tsx index 20ccb6985..930471e94 100644 --- a/src/components/Common/ImageFader/index.tsx +++ b/src/components/Common/ImageFader/index.tsx @@ -61,6 +61,7 @@ const ImageFader: ForwardRefRenderFunction = ( {...props} > ( {backdrop && (
{ >
{ tabIndex={0} > { {data.backdropPath && (
{
{ className="group ml-1 inline-flex h-full items-center xl:ml-1.5" > { {title.backdropPath && (
{ className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" > { className="group flex items-center truncate" > { data-testid="user-menu" > {
{ {data.backdropPath && (
{
{
{ {data.profilePath && (
{ > { {title.backdropPath && (
{ > { className="w-20 flex-shrink-0 scale-100 transform-gpu cursor-pointer overflow-hidden rounded-md shadow-sm transition duration-300 hover:scale-105 hover:shadow-md sm:w-28" > { {title.backdropPath && (
{ className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" > { > { >
{ {data.backdropPath && (
{
= ({
{ className="h-10 w-10 flex-shrink-0" > {