Files
channels-seerr/src/components/IssueDetails/index.tsx
Gauthier 4e48fdf2cb 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
2024-10-17 21:24:15 +08:00

760 lines
28 KiB
TypeScript

import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import IssueComment from '@app/components/IssueDetails/IssueComment';
import IssueDescription from '@app/components/IssueDetails/IssueDescription';
import { issueOptions } from '@app/components/IssueModal/constants';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import ErrorPage from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import {
ChatBubbleOvalLeftEllipsisIcon,
CheckCircleIcon,
PlayIcon,
ServerIcon,
} from '@heroicons/react/24/outline';
import { ArrowPathIcon } from '@heroicons/react/24/solid';
import { IssueStatus } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type Issue from '@server/entity/Issue';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import { Field, Form, Formik } from 'formik';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
const messages = defineMessages('components.IssueDetails', {
openedby: '#{issueId} opened {relativeTime} by {username}',
closeissue: 'Close Issue',
closeissueandcomment: 'Close with Comment',
leavecomment: 'Comment',
comments: 'Comments',
reopenissue: 'Reopen Issue',
reopenissueandcomment: 'Reopen with Comment',
issuepagetitle: 'Issue',
playonplex: 'Play on {mediaServerName}',
play4konplex: 'Play in 4K on {mediaServerName}',
openinarr: 'Open in {arr}',
openin4karr: 'Open in 4K {arr}',
toasteditdescriptionsuccess: 'Issue description edited successfully!',
toasteditdescriptionfailed:
'Something went wrong while editing the issue description.',
toaststatusupdated: 'Issue status updated successfully!',
toaststatusupdatefailed:
'Something went wrong while updating the issue status.',
issuetype: 'Type',
lastupdated: 'Last Updated',
problemseason: 'Affected Season',
allseasons: 'All Seasons',
season: 'Season {seasonNumber}',
problemepisode: 'Affected Episode',
allepisodes: 'All Episodes',
episode: 'Episode {episodeNumber}',
deleteissue: 'Delete Issue',
deleteissueconfirm: 'Are you sure you want to delete this issue?',
toastissuedeleted: 'Issue deleted successfully!',
toastissuedeletefailed: 'Something went wrong while deleting the issue.',
nocomments: 'No comments.',
unknownissuetype: 'Unknown',
commentplaceholder: 'Add a comment…',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const IssueDetails = () => {
const { addToast } = useToasts();
const router = useRouter();
const intl = useIntl();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const { user: currentUser, hasPermission } = useUser();
const { data: issueData, mutate: revalidateIssue } = useSWR<Issue>(
`/api/v1/issue/${router.query.issueId}`
);
const { data, error } = useSWR<MovieDetails | TvDetails>(
issueData?.media.tmdbId
? `/api/v1/${issueData.media.mediaType}/${issueData.media.tmdbId}`
: 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(),
});
const issueOption = issueOptions.find(
(opt) => opt.issueType === issueData?.issueType
);
const settings = useSettings();
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data || !issueData) {
return <ErrorPage statusCode={404} />;
}
const belongsToUser = issueData.createdBy.id === currentUser?.id;
const [firstComment, ...otherComments] = issueData.comments;
const editFirstComment = async (newMessage: string) => {
try {
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: newMessage }),
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
appearance: 'success',
autoDismiss: true,
});
revalidateIssue();
} catch (e) {
addToast(intl.formatMessage(messages.toasteditdescriptionfailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
try {
const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, {
method: 'POST',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toaststatusupdated), {
appearance: 'success',
autoDismiss: true,
});
revalidateIssue();
} catch (e) {
addToast(intl.formatMessage(messages.toaststatusupdatefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const deleteIssue = async () => {
try {
const res = await fetch(`/api/v1/issue/${issueData.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error();
addToast(intl.formatMessage(messages.toastissuedeleted), {
appearance: 'success',
autoDismiss: true,
});
router.push('/issues');
} catch (e) {
addToast(intl.formatMessage(messages.toastissuedeletefailed), {
appearance: 'error',
autoDismiss: true,
});
}
};
const title = isMovie(data) ? data.title : data.name;
const releaseYear = isMovie(data) ? data.releaseDate : data.firstAirDate;
return (
<div
className="media-page"
style={{
height: 493,
}}
>
<PageTitle title={[intl.formatMessage(messages.issuepagetitle), title]} />
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={showDeleteModal}
>
<Modal
title={intl.formatMessage(messages.deleteissue)}
onCancel={() => setShowDeleteModal(false)}
onOk={() => deleteIssue()}
okText={intl.formatMessage(messages.deleteissue)}
okButtonType="danger"
>
{intl.formatMessage(messages.deleteissueconfirm)}
</Modal>
</Transition>
{data.backdropPath && (
<div className="media-page-bg-image">
<CachedImage
type="tmdb"
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<div className="media-header">
<div className="media-poster">
<CachedImage
type="tmdb"
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto' }}
width={600}
height={900}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
{issueData.status === IssueStatus.OPEN && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.open)}
</Badge>
)}
{issueData.status === IssueStatus.RESOLVED && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.resolved)}
</Badge>
)}
</div>
<h1>
<Link
href={`/${
issueData.media.mediaType === MediaType.MOVIE ? 'movie' : 'tv'
}/${data.id}`}
className="hover:underline"
>
{title}
</Link>{' '}
{releaseYear && (
<span className="media-year">({releaseYear.slice(0, 4)})</span>
)}
</h1>
<span className="media-attributes">
{intl.formatMessage(messages.openedby, {
issueId: issueData.id,
username: (
<Link
href={
belongsToUser
? '/profile'
: `/users/${issueData.createdBy.id}`
}
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
>
<CachedImage
type="avatar"
src={issueData.createdBy.avatar}
alt=""
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
width={20}
height={20}
/>
<span className="font-semibold text-gray-100 transition duration-300 group-hover:text-white group-hover:underline">
{issueData.createdBy.displayName}
</span>
</Link>
),
relativeTime: (
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.createdAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
})}
</span>
</div>
</div>
<div className="relative z-10 mt-6 flex text-gray-300">
<div className="flex-1 lg:pr-4">
<IssueDescription
description={firstComment.message}
belongsToUser={belongsToUser}
commentCount={otherComments.length}
onEdit={(newMessage) => {
editFirstComment(newMessage);
}}
onDelete={() => setShowDeleteModal(true)}
/>
<div className="mt-8 lg:hidden">
<div className="media-facts">
<div className="media-fact">
<span>{intl.formatMessage(messages.issuetype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueOption?.name ?? messages.unknownissuetype
)}
</span>
</div>
{issueData.media.mediaType === MediaType.TV && (
<>
<div className="media-fact">
<span>{intl.formatMessage(messages.problemseason)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemSeason > 0
? messages.season
: messages.allseasons,
{ seasonNumber: issueData.problemSeason }
)}
</span>
</div>
{issueData.problemSeason > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.problemepisode)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemEpisode > 0
? messages.episode
: messages.allepisodes,
{ episodeNumber: issueData.problemEpisode }
)}
</span>
</div>
)}
</>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.lastupdated)}</span>
<span className="media-fact-value">
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.mediaUrl && (
<Button
as="a"
href={mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Plex',
})
: intl.formatMessage(messages.playonplex, {
mediaServerName: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Plex',
})
: intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl4k &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
</div>
</div>
<div className="mt-6">
<div className="font-semibold text-gray-100 lg:text-xl">
{intl.formatMessage(messages.comments)}
</div>
{otherComments.map((comment) => (
<IssueComment
comment={comment}
key={`issue-comment-${comment.id}`}
isReversed={issueData.createdBy.id === comment.user.id}
isActiveUser={comment.user.id === currentUser?.id}
onUpdate={() => revalidateIssue()}
/>
))}
{otherComments.length === 0 && (
<div className="mt-4 mb-10 text-gray-400">
<span>{intl.formatMessage(messages.nocomments)}</span>
</div>
)}
{(hasPermission(Permission.MANAGE_ISSUES) || belongsToUser) && (
<Formik
initialValues={{
message: '',
}}
validationSchema={CommentSchema}
onSubmit={async (values, { resetForm }) => {
const res = await fetch(
`/api/v1/issue/${issueData?.id}/comment`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.message }),
}
);
if (!res.ok) throw new Error();
revalidateIssue();
resetForm();
}}
>
{({ isValid, isSubmitting, values, handleSubmit }) => {
return (
<Form>
<div className="my-6">
<Field
id="message"
name="message"
as="textarea"
placeholder={intl.formatMessage(
messages.commentplaceholder
)}
className="h-20"
/>
<div className="mt-4 flex items-center justify-end space-x-2">
{(hasPermission(Permission.MANAGE_ISSUES) ||
belongsToUser) && (
<>
{issueData.status === IssueStatus.OPEN ? (
<Button
type="button"
buttonType="danger"
onClick={async () => {
await updateIssueStatus('resolved');
if (values.message) {
handleSubmit();
}
}}
>
<CheckCircleIcon />
<span>
{intl.formatMessage(
values.message
? messages.closeissueandcomment
: messages.closeissue
)}
</span>
</Button>
) : (
<Button
type="button"
buttonType="default"
onClick={async () => {
await updateIssueStatus('open');
if (values.message) {
handleSubmit();
}
}}
>
<ArrowPathIcon />
<span>
{intl.formatMessage(
values.message
? messages.reopenissueandcomment
: messages.reopenissue
)}
</span>
</Button>
)}
</>
)}
<Button
type="submit"
buttonType="primary"
disabled={
!isValid || isSubmitting || !values.message
}
>
<ChatBubbleOvalLeftEllipsisIcon />
<span>
{intl.formatMessage(messages.leavecomment)}
</span>
</Button>
</div>
</div>
</Form>
);
}}
</Formik>
)}
</div>
</div>
<div className="hidden lg:block lg:w-80 lg:pl-4">
<div className="media-facts">
<div className="media-fact">
<span>{intl.formatMessage(messages.issuetype)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueOption?.name ?? messages.unknownissuetype
)}
</span>
</div>
{issueData.media.mediaType === MediaType.TV && (
<>
<div className="media-fact">
<span>{intl.formatMessage(messages.problemseason)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemSeason > 0
? messages.season
: messages.allseasons,
{ seasonNumber: issueData.problemSeason }
)}
</span>
</div>
{issueData.problemSeason > 0 && (
<div className="media-fact">
<span>{intl.formatMessage(messages.problemepisode)}</span>
<span className="media-fact-value">
{intl.formatMessage(
issueData.problemEpisode > 0
? messages.episode
: messages.allepisodes,
{ episodeNumber: issueData.problemEpisode }
)}
</span>
</div>
)}
</>
)}
<div className="media-fact">
<span>{intl.formatMessage(messages.lastupdated)}</span>
<span className="media-fact-value">
<FormattedRelativeTime
value={Math.floor(
(new Date(issueData.updatedAt).getTime() - Date.now()) /
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
</span>
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.mediaUrl && (
<Button
as="a"
href={mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? intl.formatMessage(messages.playonplex, {
mediaServerName: 'Plex',
})
: intl.formatMessage(messages.playonplex, {
mediaServerName: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl && hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openinarr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<PlayIcon />
<span>
{settings.currentSettings.mediaServerType ===
MediaServerType.EMBY
? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Emby',
})
: settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Plex',
})
: intl.formatMessage(messages.play4konplex, {
mediaServerName: 'Jellyfin',
})}
</span>
</Button>
)}
{issueData?.media.serviceUrl4k &&
hasPermission(Permission.ADMIN) && (
<Button
as="a"
href={issueData?.media.serviceUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
buttonType="ghost"
>
<ServerIcon />
<span>
{intl.formatMessage(messages.openin4karr, {
arr:
issueData.media.mediaType === MediaType.MOVIE
? 'Radarr'
: 'Sonarr',
})}
</span>
</Button>
)}
</div>
</div>
</div>
<div className="extra-bottom-space" />
</div>
);
};
export default IssueDetails;