diff --git a/next.config.js b/next.config.js index 35a316c63..43aa421d9 100644 --- a/next.config.js +++ b/next.config.js @@ -10,7 +10,6 @@ module.exports = { remotePatterns: [ { hostname: 'gravatar.com' }, { hostname: 'image.tmdb.org' }, - { hostname: '*', protocol: 'https' }, ], }, webpack(config) { diff --git a/overseerr-api.yml b/overseerr-api.yml index f5e1d1622..96a4520a7 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2790,6 +2790,15 @@ paths: imageCount: type: number example: 123 + avatar: + type: object + properties: + size: + type: number + example: 123456 + imageCount: + type: number + example: 123 apiCaches: type: array items: diff --git a/package.json b/package.json index 426d774dd..9ce9330f8 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "formik": "^2.4.6", "gravatar-url": "3.1.0", "lodash": "4.17.21", + "mime": "3", "next": "^14.2.4", "node-cache": "5.1.2", "node-gyp": "9.3.1", @@ -119,6 +120,7 @@ "@types/express": "4.17.17", "@types/express-session": "1.17.6", "@types/lodash": "4.14.191", + "@types/mime": "3", "@types/node": "20.14.8", "@types/node-schedule": "2.1.0", "@types/nodemailer": "6.4.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea593fea5..7391a775a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: lodash: specifier: 4.17.21 version: 4.17.21 + mime: + specifier: '3' + version: 3.0.0 next: specifier: ^14.2.4 version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -264,6 +267,9 @@ importers: '@types/lodash': specifier: 4.14.191 version: 4.14.191 + '@types/mime': + specifier: '3' + version: 3.0.4 '@types/node': specifier: 20.14.8 version: 20.14.8 @@ -2848,6 +2854,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/mime@3.0.4': + resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==} + '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} @@ -10836,7 +10845,7 @@ snapshots: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.6.2 + semver: 7.3.8 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -10911,13 +10920,13 @@ snapshots: '@npmcli/fs@1.1.1': dependencies: '@gar/promisify': 1.1.3 - semver: 7.6.2 + semver: 7.3.8 optional: true '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.6.2 + semver: 7.3.8 '@npmcli/move-file@1.1.2': dependencies: @@ -12326,7 +12335,7 @@ snapshots: read-pkg: 5.2.0 registry-auth-token: 5.0.2 semantic-release: 19.0.5(encoding@0.1.13) - semver: 7.6.2 + semver: 7.3.8 tempy: 1.0.1 '@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))': @@ -12670,6 +12679,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/mime@3.0.4': {} + '@types/minimatch@3.0.5': {} '@types/minimist@1.2.5': {} @@ -12887,7 +12898,7 @@ snapshots: debug: 4.3.5(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 - semver: 7.6.2 + semver: 7.3.8 tsutils: 3.21.0(typescript@4.9.5) optionalDependencies: typescript: 4.9.5 @@ -17269,7 +17280,7 @@ snapshots: nopt: 5.0.0 npmlog: 6.0.2 rimraf: 3.0.2 - semver: 7.6.2 + semver: 7.3.8 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -17348,7 +17359,7 @@ snapshots: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.14.0 - semver: 7.6.2 + semver: 7.3.8 validate-npm-package-license: 3.0.4 normalize-path@3.0.0: {} diff --git a/server/index.ts b/server/index.ts index ef20674da..4ccc6fed1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -19,6 +19,7 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; +import avatarproxy from '@server/routes/avatarproxy'; import imageproxy from '@server/routes/imageproxy'; import { getAppVersion } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; @@ -202,6 +203,7 @@ app // Do not set cookies so CDNs can cache them server.use('/imageproxy', clearCookies, imageproxy); + server.use('/avatarproxy', clearCookies, avatarproxy); server.get('*', (req, res) => handle(req, res)); server.use( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 1bf40cdbc..579f11093 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -58,7 +58,7 @@ export interface CacheItem { export interface CacheResponse { apiCaches: CacheItem[]; - imageCache: Record<'tmdb', { size: number; imageCount: number }>; + imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>; } export interface StatusResponse { diff --git a/server/job/schedule.ts b/server/job/schedule.ts index b358130ce..a210988e4 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -227,6 +227,9 @@ export const startJobs = (): void => { }); // Clean TMDB image cache ImageProxy.clearCache('tmdb'); + + // Clean users avatar image cache + ImageProxy.clearCache('avatar'); }), }); diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index 195e96b94..badfe94f2 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -3,6 +3,7 @@ import type { RateLimitOptions } from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit'; import { createHash } from 'crypto'; import { promises } from 'fs'; +import mime from 'mime/lite'; import path, { join } from 'path'; type ImageResponse = { @@ -11,7 +12,7 @@ type ImageResponse = { curRevalidate: number; isStale: boolean; etag: string; - extension: string; + extension: string | null; cacheKey: string; cacheMiss: boolean; }; @@ -27,29 +28,45 @@ class ImageProxy { let deletedImages = 0; const cacheDirectory = path.join(baseCacheDirectory, key); - const files = await promises.readdir(cacheDirectory); - - for (const file of files) { - const filePath = path.join(cacheDirectory, file); - const stat = await promises.lstat(filePath); - - if (stat.isDirectory()) { - const imageFiles = await promises.readdir(filePath); - - for (const imageFile of imageFiles) { - const [, expireAtSt] = imageFile.split('.'); - const expireAt = Number(expireAtSt); - const now = Date.now(); + try { + const files = await promises.readdir(cacheDirectory); - if (now > expireAt) { - await promises.rm(path.join(filePath, imageFile)); - deletedImages += 1; + for (const file of files) { + const filePath = path.join(cacheDirectory, file); + const stat = await promises.lstat(filePath); + + if (stat.isDirectory()) { + const imageFiles = await promises.readdir(filePath); + + for (const imageFile of imageFiles) { + const [, expireAtSt] = imageFile.split('.'); + const expireAt = Number(expireAtSt); + const now = Date.now(); + + if (now > expireAt) { + await promises.rm(path.join(filePath), { + recursive: true, + }); + deletedImages += 1; + } } } } + } catch (e) { + if (e.code === 'ENOENT') { + logger.error('Directory not found', { + label: 'Image Cache', + message: e.message, + }); + } else { + logger.error('Failed to read directory', { + label: 'Image Cache', + message: e.message, + }); + } } - logger.info(`Cleared ${deletedImages} stale image(s) from cache`, { + logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, { label: 'Image Cache', }); } @@ -69,33 +86,49 @@ class ImageProxy { } private static async getDirectorySize(dir: string): Promise { - const files = await promises.readdir(dir, { - withFileTypes: true, - }); + try { + const files = await promises.readdir(dir, { + withFileTypes: true, + }); - const paths = files.map(async (file) => { - const path = join(dir, file.name); + const paths = files.map(async (file) => { + const path = join(dir, file.name); - if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); + if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); - if (file.isFile()) { - const { size } = await promises.stat(path); + if (file.isFile()) { + const { size } = await promises.stat(path); - return size; - } + return size; + } - return 0; - }); + return 0; + }); + + return (await Promise.all(paths)) + .flat(Infinity) + .reduce((i, size) => i + size, 0); + } catch (e) { + if (e.code === 'ENOENT') { + return 0; + } + } - return (await Promise.all(paths)) - .flat(Infinity) - .reduce((i, size) => i + size, 0); + return 0; } private static async getImageCount(dir: string) { - const files = await promises.readdir(dir); + try { + const files = await promises.readdir(dir); - return files.length; + return files.length; + } catch (e) { + if (e.code === 'ENOENT') { + return 0; + } + } + + return 0; } private fetch: typeof fetch; @@ -147,6 +180,27 @@ class ImageProxy { return imageResponse; } + public async clearCachedImage(path: string) { + // find cacheKey + const cacheKey = this.getCacheKey(path); + + try { + const directory = join(this.getCacheDirectory(), cacheKey); + const files = await promises.readdir(directory); + + await promises.rm(directory, { recursive: true }); + + logger.info(`Cleared ${files[0]} from cache 'avatar'`, { + label: 'Image Cache', + }); + } catch (e) { + logger.error('Failed to clear cached image', { + label: 'Image Cache', + message: e.message, + }); + } + } + private async get(cacheKey: string): Promise { try { const directory = join(this.getCacheDirectory(), cacheKey); @@ -187,16 +241,25 @@ class ImageProxy { const directory = join(this.getCacheDirectory(), cacheKey); const href = this.baseUrl + - (this.baseUrl.endsWith('/') ? '' : '/') + + (this.baseUrl.length > 0 + ? this.baseUrl.endsWith('/') + ? '' + : '/' + : '') + (path.startsWith('/') ? path.slice(1) : path); const response = await this.fetch(href); const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); - const extension = path.split('.').pop() ?? ''; - const maxAge = Number( + const extension = mime.getExtension( + response.headers.get('content-type') ?? '' + ); + + let maxAge = Number( (response.headers.get('cache-control') ?? '0').split('=')[1] ); + + if (!maxAge) maxAge = 86400; const expireAt = Date.now() + maxAge * 1000; const etag = (response.headers.get('etag') ?? '').replace(/"/g, ''); @@ -232,7 +295,7 @@ class ImageProxy { private async writeToCacheDir( dir: string, - extension: string, + extension: string | null, maxAge: number, expireAt: number, buffer: Buffer, diff --git a/server/routes/auth.ts b/server/routes/auth.ts index cd931c254..4e7f77278 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,6 +6,7 @@ import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; +import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -342,6 +343,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { }), userType: UserType.EMBY, }); + break; case MediaServerType.JELLYFIN: settings.main.mediaServerType = MediaServerType.JELLYFIN; @@ -360,6 +362,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { }), userType: UserType.JELLYFIN, }); + break; default: throw new Error('select_server_type'); @@ -407,12 +410,24 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ); // Update the users avatar with their jellyfin profile pic (incase it changed) if (account.User.PrimaryImageTag) { - user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; + const avatar = `${jellyfinHost}/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); + } + user.avatar = avatar; } else { - user.avatar = gravatarUrl(user.email || account.User.Name, { + const avatar = gravatarUrl(user.email || account.User.Name, { default: 'mm', size: 200, }); + + if (avatar !== user.avatar) { + const avatarProxy = new ImageProxy('avatar', ''); + avatarProxy.clearCachedImage(user.avatar); + } + + user.avatar = avatar; } user.jellyfinUsername = account.User.Name; @@ -462,6 +477,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ? UserType.JELLYFIN : UserType.EMBY, }); + //initialize Jellyfin/Emby users with local login const passedExplicitPassword = body.password && body.password.length > 0; if (passedExplicitPassword) { diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts new file mode 100644 index 000000000..65638df2b --- /dev/null +++ b/server/routes/avatarproxy.ts @@ -0,0 +1,32 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +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; + + try { + const imageData = await avatarImageProxy.getImage(imagePath); + + res.writeHead(200, { + 'Content-Type': `image/${imageData.meta.extension}`, + 'Content-Length': imageData.imageBuffer.length, + 'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`, + 'OS-Cache-Key': imageData.meta.cacheKey, + 'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT', + }); + + res.end(imageData.imageBuffer); + } catch (e) { + logger.error('Failed to proxy avatar image', { + imagePath, + errorMessage: e.message, + }); + } +}); + +export default router; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 30898d2a1..30c854af9 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -746,11 +746,13 @@ settingsRoutes.get('/cache', async (_req, res) => { })); const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); + const avatarImageCache = await ImageProxy.getImageStats('avatar'); return res.status(200).json({ apiCaches, imageCache: { tmdb: tmdbImageCache, + avatar: avatarImageCache, }, }); }); diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index 6dfb8ee75..7c0d52c20 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -16,8 +16,11 @@ const CachedImage = ({ src, ...props }: ImageProps) => { if (typeof imageUrl === 'string' && imageUrl.startsWith('http')) { const parsedUrl = new URL(imageUrl); - if (parsedUrl.host === 'image.tmdb.org' && currentSettings.cacheImages) { - imageUrl = imageUrl.replace('https://image.tmdb.org', '/imageproxy'); + 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; } } diff --git a/src/components/IssueDetails/IssueComment/index.tsx b/src/components/IssueDetails/IssueComment/index.tsx index 0c36ca664..ab3f59ad8 100644 --- a/src/components/IssueDetails/IssueComment/index.tsx +++ b/src/components/IssueDetails/IssueComment/index.tsx @@ -1,4 +1,5 @@ import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; import Modal from '@app/components/Common/Modal'; import { Permission, useUser } from '@app/hooks/useUser'; import defineMessages from '@app/utils/defineMessages'; @@ -6,7 +7,6 @@ import { Menu, Transition } from '@headlessui/react'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import type { default as IssueCommentType } from '@server/entity/IssueComment'; import { Field, Form, Formik } from 'formik'; -import Image from 'next/image'; import Link from 'next/link'; import { Fragment, useState } from 'react'; import { FormattedRelativeTime, useIntl } from 'react-intl'; @@ -88,8 +88,8 @@ const IssueComment = ({ - { } className="group ml-1 inline-flex h-full items-center xl:ml-1.5" > - diff --git a/src/components/IssueList/IssueItem/index.tsx b/src/components/IssueList/IssueItem/index.tsx index 1b52be3e1..c49a57d4b 100644 --- a/src/components/IssueList/IssueItem/index.tsx +++ b/src/components/IssueList/IssueItem/index.tsx @@ -11,7 +11,6 @@ import { MediaType } from '@server/constants/media'; import type Issue from '@server/entity/Issue'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; -import Image from 'next/image'; import Link from 'next/link'; import { useInView } from 'react-intersection-observer'; import { FormattedRelativeTime, useIntl } from 'react-intl'; @@ -226,8 +225,8 @@ const IssueItem = ({ issue }: IssueItemProps) => { href={`/users/${issue.createdBy.id}`} className="group flex items-center truncate" > - { className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500" data-testid="user-menu" > - {
- - {user.displayName} - {user.displayName} { className="group flex items-center" > - { className="group flex items-center" > - - - { className="group flex items-center truncate" > - { className="group flex items-center truncate" > - - - {appDataPath}/cache/images.', imagecachecount: 'Images Cached', imagecachesize: 'Total Cache Size', + usersavatars: "Users' Avatars", } ); @@ -573,6 +574,19 @@ const SettingsJobs = () => { {formatBytes(cacheData?.imageCache.tmdb.size ?? 0)} + + + {intl.formatMessage(messages.usersavatars)} (avatar) + + + {intl.formatNumber( + cacheData?.imageCache.avatar.imageCount ?? 0 + )} + + + {formatBytes(cacheData?.imageCache.avatar.size ?? 0)} + +
diff --git a/src/components/UserList/JellyfinImportModal.tsx b/src/components/UserList/JellyfinImportModal.tsx index 36dbe0aaa..e95a0a7d3 100644 --- a/src/components/UserList/JellyfinImportModal.tsx +++ b/src/components/UserList/JellyfinImportModal.tsx @@ -1,11 +1,11 @@ import Alert from '@app/components/Common/Alert'; +import CachedImage from '@app/components/Common/CachedImage'; import Modal from '@app/components/Common/Modal'; import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { MediaServerType } from '@server/constants/server'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; -import Image from 'next/image'; import { useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -249,7 +249,7 @@ const JellyfinImportModal: React.FC = ({
- { href={`/users/${user.id}`} className="h-10 w-10 flex-shrink-0" > - {
-