From a0f80fe7647ef4a9025ca93407cd21ddc640fed1 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 16 Oct 2024 03:50:21 +0800 Subject: [PATCH 1/5] fix: use jellyfinMediaId4k for mediaUrl4k (#1006) Fixes the issue where mediaUrl4K was still using the non-4k mediaId despite having the correct 4k Id stored. fix #520 --- server/entity/Media.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 4f64178a..de10cebc 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -231,7 +231,7 @@ class Media { this.mediaUrl = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; } if (this.jellyfinMediaId4k) { - this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId}&context=home&serverId=${serverId}`; + this.mediaUrl4k = `${jellyfinHost}/web/index.html#!/${pageName}?id=${this.jellyfinMediaId4k}&context=home&serverId=${serverId}`; } } } From 4945b5429848b36fc0ee41cf0277ed79f53d8286 Mon Sep 17 00:00:00 2001 From: Fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 17 Oct 2024 07:25:06 +0800 Subject: [PATCH 2/5] fix: fetch override to attach XSRF token to fix csrfProtection issue (#1014) During the migration from Axios to fetch, we overlooked the fact that Axios automatically handled CSRF tokens, while fetch does not. When CSRF protection was turned on, requests were failing with an "invalid CSRF token" error for users accessing the app even via HTTPS. This commit overrides fetch to ensure that the CSRF token is included in all requests. fix #1011 --- src/pages/_app.tsx | 1 + src/utils/fetchOverride.ts | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/utils/fetchOverride.ts diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index ba0677c6..e5704052 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -12,6 +12,7 @@ import { SettingsProvider } from '@app/context/SettingsContext'; import { UserContext } from '@app/context/UserContext'; import type { User } from '@app/hooks/useUser'; import '@app/styles/globals.css'; +import '@app/utils/fetchOverride'; import { polyfillIntl } from '@app/utils/polyfillIntl'; import { MediaServerType } from '@server/constants/server'; import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces'; diff --git a/src/utils/fetchOverride.ts b/src/utils/fetchOverride.ts new file mode 100644 index 00000000..e0a90012 --- /dev/null +++ b/src/utils/fetchOverride.ts @@ -0,0 +1,46 @@ +const getCsrfToken = (): string | null => { + if (typeof window !== 'undefined') { + const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/); + return match ? decodeURIComponent(match[1]) : null; + } + return null; +}; + +const isSameOrigin = (url: RequestInfo | URL): boolean => { + const parsedUrl = new URL( + url instanceof Request ? url.url : url.toString(), + window.location.origin + ); + return parsedUrl.origin === window.location.origin; +}; + +// We are using a custom fetch implementation to add the X-XSRF-TOKEN heade +// to all requests. This is required when CSRF protection is enabled. +if (typeof window !== 'undefined') { + const originalFetch: typeof fetch = window.fetch; + + (window as typeof globalThis).fetch = async ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + if (!isSameOrigin(input)) { + return originalFetch(input, init); + } + + const csrfToken = getCsrfToken(); + + const headers = { + ...(init?.headers || {}), + ...(csrfToken ? { 'XSRF-TOKEN': csrfToken } : {}), + }; + + const newInit: RequestInit = { + ...init, + headers, + }; + + return originalFetch(input, newInit); + }; +} + +export {}; From 9de304d17a222c6b48294b0b83f606d378949a63 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 17 Oct 2024 07:25:36 +0800 Subject: [PATCH 3/5] docs: add M0NsTeRRR as a contributor for security (#1015) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 9 +++++++++ README.md | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 91017932..3614dbd1 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -439,6 +439,15 @@ "contributions": [ "code" ] + }, + { + "login": "M0NsTeRRR", + "name": "Ludovic Ortega", + "avatar_url": "https://avatars.githubusercontent.com/u/37785089?v=4", + "profile": "https://github.com/M0NsTeRRR", + "contributions": [ + "security" + ] } ] } diff --git a/README.md b/README.md index f91fe384..fb6c8790 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Translation status GitHub -All Contributors +All Contributors **Jellyseerr** is a free and open source software application for managing requests for your media library. @@ -146,6 +146,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Baraa
Baraa

💻 Francisco Sales
Francisco Sales

💻 Oliver Laing
Oliver Laing

💻 + Ludovic Ortega
Ludovic Ortega

🛡️ From a351264b878b2660ae7a6415f26d38b52015c591 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 17 Oct 2024 12:37:19 +0200 Subject: [PATCH 4/5] fix: handle non-existent rottentomatoes rating (#1018) This fixes a bug where some media don't have any rottentomatoes ratings. --- server/api/rating/rottentomatoes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/rating/rottentomatoes.ts b/server/api/rating/rottentomatoes.ts index e86c2488..f4fbe12b 100644 --- a/server/api/rating/rottentomatoes.ts +++ b/server/api/rating/rottentomatoes.ts @@ -182,7 +182,7 @@ class RottenTomatoes extends ExternalAPI { ); } - if (!tvshow) { + if (!tvshow || !tvshow.rottenTomatoes) { return null; } From 4e48fdf2cb9f76ae5c25073b585718650abd3288 Mon Sep 17 00:00:00 2001 From: Gauthier Date: Thu, 17 Oct 2024 15:24:15 +0200 Subject: [PATCH 5/5] 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 4e7f7727..560f04d5 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 65638df2..e6f6f3b5 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 30c854af..3d6b6b0d 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 f8a0d41a..83ad0910 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 217f4cef..a752e95f 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; + let imageUrl: string; - if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { - const parsedUrl = new URL(imageUrl); - - 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 20ccb698..930471e9 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" > {