diff --git a/.all-contributorsrc b/.all-contributorsrc index 3cf5e765c..faa3f7534 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -737,6 +737,24 @@ "contributions": [ "translation" ] + }, + { + "login": "Eclipseop", + "name": "Mackenzie", + "avatar_url": "https://avatars.githubusercontent.com/u/5846213?v=4", + "profile": "https://github.com/Eclipseop", + "contributions": [ + "code" + ] + }, + { + "login": "s0up4200", + "name": "soup", + "avatar_url": "https://avatars.githubusercontent.com/u/18177310?v=4", + "profile": "https://github.com/s0up4200", + "contributions": [ + "doc" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", @@ -745,5 +763,6 @@ "projectOwner": "sct", "repoType": "github", "repoHost": "https://github.com", - "skipCi": false + "skipCi": false, + "commitConvention": "angular" } diff --git a/.gitignore b/.gitignore index 70a5d6f2f..9a8925ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ tsconfig.tsbuildinfo # Webstorm .idea + +# Config Cache Directory +config/cache diff --git a/docs/extending-overseerr/fail2ban.md b/docs/extending-overseerr/fail2ban.md index 1cf9131f0..4f2b1c594 100644 --- a/docs/extending-overseerr/fail2ban.md +++ b/docs/extending-overseerr/fail2ban.md @@ -11,4 +11,4 @@ To use Fail2ban with Overseerr, create a new file named `overseerr.local` in you failregex = .*\[warn\]\[API\]\: Failed sign-in attempt.*"ip":"" ``` -You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. +You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. diff --git a/docs/using-overseerr/settings/README.md b/docs/using-overseerr/settings/README.md index 820430736..477129fc9 100644 --- a/docs/using-overseerr/settings/README.md +++ b/docs/using-overseerr/settings/README.md @@ -40,6 +40,14 @@ If you enable this setting and find yourself unable to access Overseerr, you can This setting is **disabled** by default. +### Enable Image Caching + +When enabled, Overseerr will proxy and cache images from pre-configured sources (such as TMDB). This can use a significant amount of disk space. + +Images are saved in the `config/cache/images` and stale images are cleared out every 24 hours. + +You should enable this if you are having issues with loading images directly from TMDB in your browser. + ### Display Language Set the default display language for Overseerr. Users can override this setting in their user settings. diff --git a/overseerr-api.yml b/overseerr-api.yml index 33052ad4f..128729f06 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -2667,29 +2667,44 @@ paths: content: application/json: schema: - type: array - items: - type: object - properties: - id: - type: string - example: cache-id - name: - type: string - example: cache name - stats: + type: object + properties: + imageCache: + type: object + properties: + tmdb: + type: object + properties: + size: + type: number + example: 123456 + imageCount: + type: number + example: 123 + apiCaches: + type: array + items: type: object properties: - hits: - type: number - misses: - type: number - keys: - type: number - ksize: - type: number - vsize: - type: number + id: + type: string + example: cache-id + name: + type: string + example: cache name + stats: + type: object + properties: + hits: + type: number + misses: + type: number + keys: + type: number + ksize: + type: number + vsize: + type: number /settings/cache/{cacheId}/flush: post: summary: Flush a specific cache @@ -4838,9 +4853,13 @@ paths: type: number example: 123 seasons: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + minimum: 1 + - type: string + enum: [all] is4k: type: boolean example: false @@ -4919,7 +4938,7 @@ paths: $ref: '#/components/schemas/MediaRequest' put: summary: Update MediaRequest - description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission. + description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. tags: - request parameters: @@ -4930,6 +4949,37 @@ paths: example: '1' schema: type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + seasons: + type: array + items: + type: number + minimum: 1 + is4k: + type: boolean + example: false + serverId: + type: number + profileId: + type: number + rootFolder: + type: string + languageProfileId: + type: number + userId: + type: number + nullable: true + required: + - mediaType responses: '200': description: Succesfully updated request diff --git a/package.json b/package.json index 333fdfcef..4616ece9b 100644 --- a/package.json +++ b/package.json @@ -225,7 +225,7 @@ { "path": "semantic-release-docker-buildx", "buildArgs": { - "COMMIT_TAG": "$GITHUB_SHA" + "COMMIT_TAG": "$GIT_SHA" }, "imageNames": [ "fallenbagel/jellyseerr" diff --git a/server/entity/Media.ts b/server/entity/Media.ts index cf6f5a280..12228200f 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -205,8 +205,8 @@ class Media { ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; if (this.jellyfinMediaId) { diff --git a/server/entity/User.ts b/server/entity/User.ts index b5f781109..8780e2d8a 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -39,7 +39,7 @@ export class User { return users.map((u) => u.filter(showFiltered)); } - static readonly filteredFields: string[] = ['email']; + static readonly filteredFields: string[] = ['email', 'plexId']; public displayName: string; @@ -76,7 +76,7 @@ export class User { @Column({ type: 'integer', default: UserType.PLEX }) public userType: UserType; - @Column({ nullable: true }) + @Column({ nullable: true, select: true }) public plexId?: number; @Column({ nullable: true }) diff --git a/server/index.ts b/server/index.ts index 615e789bf..b6eb13f5f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,7 @@ import WebPushAgent from '@server/lib/notifications/agents/webpush'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import routes from '@server/routes'; +import imageproxy from '@server/routes/imageproxy'; import { getAppVersion } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; @@ -186,6 +187,9 @@ app next(); }); server.use('/api/v1', routes); + + server.use('/imageproxy', imageproxy); + server.get('*', (req, res) => handle(req, res)); server.use( ( diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index bafd15b1f..32ed6a542 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -54,6 +54,11 @@ export interface CacheItem { }; } +export interface CacheResponse { + apiCaches: CacheItem[]; + imageCache: Record<'tmdb', { size: number; imageCount: number }>; +} + export interface StatusResponse { version: string; commitTag: string; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 356c475e5..6d9600736 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,5 +1,6 @@ import { MediaServerType } from '@server/constants/server'; import downloadTracker from '@server/lib/downloadtracker'; +import ImageProxy from '@server/lib/imageproxy'; import { plexFullScanner, plexRecentScanner } from '@server/lib/scanners/plex'; import { radarrScanner } from '@server/lib/scanners/radarr'; import { sonarrScanner } from '@server/lib/scanners/sonarr'; @@ -181,5 +182,21 @@ export const startJobs = (): void => { }), }); + // Run image cache cleanup every 5 minutes + scheduledJobs.push({ + id: 'image-cache-cleanup', + name: 'Image Cache Cleanup', + type: 'process', + interval: 'long', + cronSchedule: jobs['image-cache-cleanup'].schedule, + job: schedule.scheduleJob(jobs['image-cache-cleanup'].schedule, () => { + logger.info('Starting scheduled job: Image Cache Cleanup', { + label: 'Jobs', + }); + // Clean TMDB image cache + ImageProxy.clearCache('tmdb'); + }), + }); + logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts new file mode 100644 index 000000000..34a097d5f --- /dev/null +++ b/server/lib/imageproxy.ts @@ -0,0 +1,268 @@ +import logger from '@server/logger'; +import axios from 'axios'; +import rateLimit, { type rateLimitOptions } from 'axios-rate-limit'; +import { createHash } from 'crypto'; +import { promises } from 'fs'; +import path, { join } from 'path'; + +type ImageResponse = { + meta: { + revalidateAfter: number; + curRevalidate: number; + isStale: boolean; + etag: string; + extension: string; + cacheKey: string; + cacheMiss: boolean; + }; + imageBuffer: Buffer; +}; + +class ImageProxy { + public static async clearCache(key: string) { + let deletedImages = 0; + const cacheDirectory = path.join( + __dirname, + '../../config/cache/images/', + 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(); + + if (now > expireAt) { + await promises.rm(path.join(filePath, imageFile)); + deletedImages += 1; + } + } + } + } + + logger.info(`Cleared ${deletedImages} stale image(s) from cache`, { + label: 'Image Cache', + }); + } + + public static async getImageStats( + key: string + ): Promise<{ size: number; imageCount: number }> { + const cacheDirectory = path.join( + __dirname, + '../../config/cache/images/', + key + ); + + const imageTotalSize = await ImageProxy.getDirectorySize(cacheDirectory); + const imageCount = await ImageProxy.getImageCount(cacheDirectory); + + return { + size: imageTotalSize, + imageCount, + }; + } + + private static async getDirectorySize(dir: string): Promise { + const files = await promises.readdir(dir, { + withFileTypes: true, + }); + + const paths = files.map(async (file) => { + const path = join(dir, file.name); + + if (file.isDirectory()) return await ImageProxy.getDirectorySize(path); + + if (file.isFile()) { + const { size } = await promises.stat(path); + + return size; + } + + return 0; + }); + + return (await Promise.all(paths)) + .flat(Infinity) + .reduce((i, size) => i + size, 0); + } + + private static async getImageCount(dir: string) { + const files = await promises.readdir(dir); + + return files.length; + } + + private axios; + private cacheVersion; + private key; + + constructor( + key: string, + baseUrl: string, + options: { + cacheVersion?: number; + rateLimitOptions?: rateLimitOptions; + } = {} + ) { + this.cacheVersion = options.cacheVersion ?? 1; + this.key = key; + this.axios = axios.create({ + baseURL: baseUrl, + }); + + if (options.rateLimitOptions) { + this.axios = rateLimit(this.axios, options.rateLimitOptions); + } + } + + public async getImage(path: string): Promise { + const cacheKey = this.getCacheKey(path); + + const imageResponse = await this.get(cacheKey); + + if (!imageResponse) { + const newImage = await this.set(path, cacheKey); + + if (!newImage) { + throw new Error('Failed to load image'); + } + + return newImage; + } + + // If the image is stale, we will revalidate it in the background. + if (imageResponse.meta.isStale) { + this.set(path, cacheKey); + } + + return imageResponse; + } + + private async get(cacheKey: string): Promise { + try { + const directory = join(this.getCacheDirectory(), cacheKey); + const files = await promises.readdir(directory); + const now = Date.now(); + + for (const file of files) { + const [maxAgeSt, expireAtSt, etag, extension] = file.split('.'); + const buffer = await promises.readFile(join(directory, file)); + const expireAt = Number(expireAtSt); + const maxAge = Number(maxAgeSt); + + return { + meta: { + curRevalidate: maxAge, + revalidateAfter: maxAge * 1000 + now, + isStale: now > expireAt, + etag, + extension, + cacheKey, + cacheMiss: false, + }, + imageBuffer: buffer, + }; + } + } catch (e) { + // No files. Treat as empty cache. + } + + return null; + } + + private async set( + path: string, + cacheKey: string + ): Promise { + try { + const directory = join(this.getCacheDirectory(), cacheKey); + const response = await this.axios.get(path, { + responseType: 'arraybuffer', + }); + + const buffer = Buffer.from(response.data, 'binary'); + const extension = path.split('.').pop() ?? ''; + const maxAge = Number(response.headers['cache-control'].split('=')[1]); + const expireAt = Date.now() + maxAge * 1000; + const etag = response.headers.etag.replace(/"/g, ''); + + await this.writeToCacheDir( + directory, + extension, + maxAge, + expireAt, + buffer, + etag + ); + + return { + meta: { + curRevalidate: maxAge, + revalidateAfter: expireAt, + isStale: false, + etag, + extension, + cacheKey, + cacheMiss: true, + }, + imageBuffer: buffer, + }; + } catch (e) { + logger.debug('Something went wrong caching image.', { + label: 'Image Cache', + errorMessage: e.message, + }); + return null; + } + } + + private async writeToCacheDir( + dir: string, + extension: string, + maxAge: number, + expireAt: number, + buffer: Buffer, + etag: string + ) { + const filename = join(dir, `${maxAge}.${expireAt}.${etag}.${extension}`); + + await promises.rm(dir, { force: true, recursive: true }).catch(() => { + // do nothing + }); + + await promises.mkdir(dir, { recursive: true }); + await promises.writeFile(filename, buffer); + } + + private getCacheKey(path: string) { + return this.getHash([this.key, this.cacheVersion, path]); + } + + private getHash(items: (string | number | Buffer)[]) { + const hash = createHash('sha256'); + for (const item of items) { + if (typeof item === 'number') hash.update(String(item)); + else { + hash.update(item); + } + } + // See https://en.wikipedia.org/wiki/Base64#Filenames + return hash.digest('base64').replace(/\//g, '-'); + } + + private getCacheDirectory() { + return path.join(__dirname, '../../config/cache/images/', this.key); + } +} + +export default ImageProxy; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 29e2fcf13..930ca2804 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -38,7 +38,7 @@ export interface PlexSettings { export interface JellyfinSettings { name: string; - hostname?: string; + hostname: string; externalHostname?: string; libraries: Library[]; serverId: string; @@ -263,7 +263,8 @@ export type JobId = | 'download-sync' | 'download-sync-reset' | 'jellyfin-recently-added-sync' - | 'jellyfin-full-sync'; + | 'jellyfin-full-sync' + | 'image-cache-cleanup'; interface AllSettings { clientId: string; @@ -446,6 +447,9 @@ class Settings { 'jellyfin-full-sync': { schedule: '0 0 3 * * *', }, + 'image-cache-cleanup': { + schedule: '0 0 5 * * *', + }, }, }; if (initialSettings) { diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 0280a4286..1dabcdf31 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -89,13 +89,28 @@ authRoutes.post('/plex', async (req, res, next) => { await userRepository.save(user); } else { const mainUser = await userRepository.findOneOrFail({ - select: { id: true, plexToken: true, plexId: true }, + select: { id: true, plexToken: true, plexId: true, email: true }, where: { id: 1 }, }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + if (!account.id) { + logger.error('Plex ID was missing from Plex.tv response', { + label: 'API', + ip: req.ip, + email: account.email, + plexUsername: account.username, + }); + + return next({ + status: 500, + message: 'Something went wrong. Try again.', + }); + } + if ( account.id === mainUser.plexId || + (account.email === mainUser.email && !mainUser.plexId) || (await mainPlexTv.checkUserAccess(account.id)) ) { if (user) { @@ -226,7 +241,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { const hostname = settings.jellyfin.hostname !== '' ? settings.jellyfin.hostname - : body.hostname; + : body.hostname ?? ''; const { externalHostname } = getSettings().jellyfin; // Try to find deviceId that corresponds to jellyfin user, else generate a new one @@ -249,8 +264,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => { ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; const account = await jellyfinserver.login(body.username, body.password); diff --git a/server/routes/imageproxy.ts b/server/routes/imageproxy.ts new file mode 100644 index 000000000..6cf104f52 --- /dev/null +++ b/server/routes/imageproxy.ts @@ -0,0 +1,39 @@ +import ImageProxy from '@server/lib/imageproxy'; +import logger from '@server/logger'; +import { Router } from 'express'; + +const router = Router(); +const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', { + rateLimitOptions: { + maxRequests: 20, + maxRPS: 50, + }, +}); + +/** + * Image Proxy + */ +router.get('/*', async (req, res) => { + const imagePath = req.path.replace('/image', ''); + try { + const imageData = await tmdbImageProxy.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 image', { + imagePath, + errorMessage: e.message, + }); + res.status(500).send(); + } +}); + +export default router; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index dc9dbcb42..005076e10 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -16,9 +16,10 @@ import { jobJellyfinFullSync } from '@server/job/jellyfinsync'; import { scheduledJobs } from '@server/job/schedule'; import type { AvailableCacheIds } from '@server/lib/cache'; import cacheManager from '@server/lib/cache'; +import ImageProxy from '@server/lib/imageproxy'; import { Permission } from '@server/lib/permissions'; import { plexFullScanner } from '@server/lib/scanners/plex'; -import type { Library, MainSettings } from '@server/lib/settings'; +import type { JobId, Library, MainSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; @@ -312,8 +313,8 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; const userRepository = getRepository(User); const admin = await userRepository.findOneOrFail({ @@ -604,7 +605,7 @@ settingsRoutes.post<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { }); }); -settingsRoutes.post<{ jobId: string }>( +settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/cancel', (req, res, next) => { const scheduledJob = scheduledJobs.find( @@ -631,7 +632,7 @@ settingsRoutes.post<{ jobId: string }>( } ); -settingsRoutes.post<{ jobId: string }>( +settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/schedule', (req, res, next) => { const scheduledJob = scheduledJobs.find( @@ -666,16 +667,23 @@ settingsRoutes.post<{ jobId: string }>( } ); -settingsRoutes.get('/cache', (req, res) => { - const caches = cacheManager.getAllCaches(); +settingsRoutes.get('/cache', async (_req, res) => { + const cacheManagerCaches = cacheManager.getAllCaches(); - return res.status(200).json( - Object.values(caches).map((cache) => ({ - id: cache.id, - name: cache.name, - stats: cache.getStats(), - })) - ); + const apiCaches = Object.values(cacheManagerCaches).map((cache) => ({ + id: cache.id, + name: cache.name, + stats: cache.getStats(), + })); + + const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); + + return res.status(200).json({ + apiCaches, + imageCache: { + tmdb: tmdbImageCache, + }, + }); }); settingsRoutes.post<{ cacheId: AvailableCacheIds }>( diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 16d8a50be..486ebc368 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -502,8 +502,8 @@ router.post( ? externalHostname : hostname; - jellyfinHost = jellyfinHost!.endsWith('/') - ? jellyfinHost!.slice(0, -1) + jellyfinHost = jellyfinHost.endsWith('/') + ? jellyfinHost.slice(0, -1) : jellyfinHost; jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); const jellyfinUsers = await jellyfinClient.getUsers(); diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index b16959372..6dfb8ee75 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -1,18 +1,27 @@ import useSettings from '@app/hooks/useSettings'; -import type { ImageProps } from 'next/image'; +import type { ImageLoader, ImageProps } from 'next/image'; import Image from 'next/image'; +const imageLoader: ImageLoader = ({ src }) => src; + /** * The CachedImage component should be used wherever * we want to offer the option to locally cache images. - * - * It uses the `next/image` Image component but overrides - * the `unoptimized` prop based on the application setting `cacheImages`. **/ -const CachedImage = (props: ImageProps) => { +const CachedImage = ({ src, ...props }: ImageProps) => { const { currentSettings } = useSettings(); - return ; + let imageUrl = src; + + 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'); + } + } + + return ; }; export default CachedImage; diff --git a/src/components/CompanyCard/index.tsx b/src/components/CompanyCard/index.tsx index 762d1a08b..13b92a333 100644 --- a/src/components/CompanyCard/index.tsx +++ b/src/components/CompanyCard/index.tsx @@ -1,3 +1,4 @@ +import CachedImage from '@app/components/Common/CachedImage'; import Link from 'next/link'; import { useState } from 'react'; @@ -30,11 +31,15 @@ const CompanyCard = ({ image, url, name }: CompanyCardProps) => { role="link" tabIndex={0} > - {name} +
+ +
{ : null ); + const { mediaUrl, mediaUrl4k } = useDeepLinks({ + mediaUrl: data?.mediaInfo?.mediaUrl, + mediaUrl4k: data?.mediaInfo?.mediaUrl4k, + iOSPlexUrl: data?.mediaInfo?.iOSPlexUrl, + iOSPlexUrl4k: data?.mediaInfo?.iOSPlexUrl4k, + }); + const CommentSchema = Yup.object().shape({ message: Yup.string().required(), }); @@ -359,7 +367,7 @@ const IssueDetails = () => { {issueData?.media.mediaUrl && (