Files
requestarr/src/components/Blacklist/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

421 lines
15 KiB
TypeScript

import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import useDebouncedState from '@app/hooks/useDebouncedState';
import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import {
ChevronLeftIcon,
ChevronRightIcon,
MagnifyingGlassIcon,
TrashIcon,
} from '@heroicons/react/24/solid';
import type {
BlacklistItem,
BlacklistResultsResponse,
} from '@server/interfaces/api/blacklistInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Link from 'next/link';
import { useRouter } from 'next/router';
import type { ChangeEvent } from 'react';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Blacklist', {
blacklistsettings: 'Blacklist Settings',
blacklistSettingsDescription: 'Manage blacklisted media.',
mediaName: 'Name',
mediaType: 'Type',
mediaTmdbId: 'tmdb Id',
blacklistdate: 'date',
blacklistedby: '{date} by {user}',
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const Blacklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
useDebouncedState('');
const router = useRouter();
const intl = useIntl();
const page = router.query.page ? Number(router.query.page) : 1;
const pageIndex = page - 1;
const updateQueryParams = useUpdateQueryParams({ page: page.toString() });
const {
data,
error,
mutate: revalidate,
} = useSWR<BlacklistResultsResponse>(
`/api/v1/blacklist/?take=${currentPageSize}
&skip=${pageIndex * currentPageSize}
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
{
refreshInterval: 0,
revalidateOnFocus: false,
}
);
// check if there's no data and no errors in the table
// so as to show a spinner inside the table and not refresh the whole component
if (!data && error) {
return <Error statusCode={500} />;
}
const searchItem = (e: ChangeEvent<HTMLInputElement>) => {
// Remove the "page" query param from the URL
// so that the "skip" query param on line 62 is empty
// and the search returns results without skipping items
if (router.query.page) router.replace(router.basePath);
setSearchFilter(e.target.value as string);
};
const hasNextPage = data && data.pageInfo.pages > pageIndex + 1;
const hasPrevPage = pageIndex > 0;
return (
<>
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
</div>
</div>
{!data ? (
<LoadingSpinner />
) : data.results.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center py-24 text-white">
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
</div>
) : (
data.results.map((item: BlacklistItem) => {
return (
<div className="py-2" key={`request-list-${item.tmdbId}`}>
<BlacklistedItem item={item} revalidateList={revalidate} />
</div>
);
})
)}
<div className="actions">
<nav
className="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0"
aria-label="Pagination"
>
<div className="hidden lg:flex lg:flex-1">
<p className="text-sm">
{data &&
(data?.results.length ?? 0) > 0 &&
intl.formatMessage(globalMessages.showingresults, {
from: pageIndex * currentPageSize + 1,
to:
data.results.length < currentPageSize
? pageIndex * currentPageSize + data.results.length
: (pageIndex + 1) * currentPageSize,
total: data.pageInfo.results,
strong: (msg: React.ReactNode) => (
<span className="font-medium">{msg}</span>
),
})}
</p>
</div>
<div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center">
<span className="-mt-3 items-center truncate text-sm sm:mt-0">
{intl.formatMessage(globalMessages.resultsperpage, {
pageSize: (
<select
id="pageSize"
name="pageSize"
onChange={(e) => {
setCurrentPageSize(Number(e.target.value));
router
.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
})
.then(() => window.scrollTo(0, 0));
}}
value={currentPageSize}
className="short inline"
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
),
})}
</span>
</div>
<div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end">
<Button
disabled={!hasPrevPage}
onClick={() => updateQueryParams('page', (page - 1).toString())}
>
<ChevronLeftIcon />
<span>{intl.formatMessage(globalMessages.previous)}</span>
</Button>
<Button
disabled={!hasNextPage}
onClick={() => updateQueryParams('page', (page + 1).toString())}
>
<span>{intl.formatMessage(globalMessages.next)}</span>
<ChevronRightIcon />
</Button>
</div>
</nav>
</div>
</>
);
};
export default Blacklist;
interface BlacklistedItemProps {
item: BlacklistItem;
revalidateList: () => void;
}
const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const { addToast } = useToasts();
const { ref, inView } = useInView({
triggerOnce: true,
});
const intl = useIntl();
const { hasPermission } = useUser();
const url =
item.mediaType === 'movie'
? `/api/v1/movie/${item.tmdbId}`
: `/api/v1/tv/${item.tmdbId}`;
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null
);
if (!title && !error) {
return (
<div
className="h-64 w-full animate-pulse rounded-xl bg-gray-800 xl:h-28"
ref={ref}
/>
);
}
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
const res = await fetch('/api/v1/blacklist/' + tmdbId, {
method: 'DELETE',
});
if (res.status === 204) {
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} else {
addToast(intl.formatMessage(globalMessages.blacklistError), {
appearance: 'error',
autoDismiss: true,
});
}
revalidateList();
setIsUpdating(false);
};
return (
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
{title && title.backdropPath && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
fill
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)',
}}
/>
</div>
)}
<div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row">
<div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3">
<Link
href={
item.mediaType === 'movie'
? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}`
}
className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105"
>
<CachedImage
type="tmdb"
src={
title?.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
sizes="100vw"
style={{ width: '100%', height: 'auto', objectFit: 'cover' }}
width={600}
height={900}
/>
</Link>
<div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4">
<div className="pt-0.5 text-xs font-medium text-white sm:pt-1">
{title &&
(isMovie(title)
? title.releaseDate
: title.firstAirDate
)?.slice(0, 4)}
</div>
<Link
href={
item.mediaType === 'movie'
? `/movie/${item.tmdbId}`
: `/tv/${item.tmdbId}`
}
>
<span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl">
{title && (isMovie(title) ? title.title : title.name)}
</span>
</Link>
</div>
</div>
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
<div className="card-field">
<span className="card-field-name">Status</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
</Badge>
</div>
{item.createdAt && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.blacklisted)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.blacklistedby, {
date: (
<FormattedRelativeTime
value={Math.floor(
(new Date(item.createdAt).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
),
user: (
<Link href={`/users/${item.user.id}`}>
<span className="group flex items-center truncate">
<CachedImage
type="avatar"
src={item.user.avatar}
alt=""
className="avatar-sm ml-1.5"
width={20}
height={20}
style={{ objectFit: 'cover' }}
/>
<span className="ml-1 truncate text-sm font-semibold group-hover:text-white group-hover:underline">
{item.user.displayName}
</span>
</span>
</Link>
),
})}
</span>
</div>
)}
<div className="card-field">
{item.mediaType === 'movie' ? (
<div className="pointer-events-none z-40 self-start rounded-full border border-blue-500 bg-blue-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.movie)}
</div>
</div>
) : (
<div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md">
<div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5">
{intl.formatMessage(globalMessages.tvshow)}
</div>
</div>
)}
</div>
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
{hasPermission(Permission.MANAGE_BLACKLIST) && (
<ConfirmButton
onClick={() =>
removeFromBlacklist(
item.tmdbId,
title && (isMovie(title) ? title.title : title.name)
)
}
confirmText={intl.formatMessage(
isUpdating ? globalMessages.deleting : globalMessages.areyousure
)}
className={`w-full ${
isUpdating ? 'pointer-events-none opacity-50' : ''
}`}
>
<TrashIcon />
<span>
{intl.formatMessage(globalMessages.removefromBlacklist)}
</span>
</ConfirmButton>
)}
</div>
</div>
);
};