Compare commits

..

1 Commits

17 changed files with 149 additions and 128 deletions

View File

@@ -7,6 +7,7 @@
"applicationTitle": "Overseerr",
"applicationUrl": "",
"csrfProtection": false,
"cspFrameAncestorDomains": "",
"cacheImages": false,
"defaultPermissions": 32,
"defaultQuotas": {

View File

@@ -168,6 +168,9 @@ components:
csrfProtection:
type: boolean
example: false
cspFrameAncestorDomains:
type: string
example: 'example.com'
hideAvailable:
type: boolean
example: false

View File

@@ -61,6 +61,7 @@
"express-session": "1.17.3",
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"helmet": "^7.1.0",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4",

9
pnpm-lock.yaml generated
View File

@@ -95,6 +95,9 @@ importers:
gravatar-url:
specifier: 3.1.0
version: 3.1.0
helmet:
specifier: ^7.1.0
version: 7.1.0
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -5292,6 +5295,10 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
helmet@7.1.0:
resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==}
engines: {node: '>=16.0.0'}
hermes-estree@0.19.1:
resolution: {integrity: sha512-daLGV3Q2MKk8w4evNMKwS8zBE/rcpA800nu1Q5kM08IKijoSnPe9Uo1iIxzPKRkn95IxxsgBMPeYHt3VG4ej2g==}
@@ -15595,6 +15602,8 @@ snapshots:
he@1.2.0: {}
helmet@7.1.0: {}
hermes-estree@0.19.1: {}
hermes-estree@0.20.1: {}

View File

@@ -76,7 +76,7 @@ class ExternalAPI {
}
const data = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) {
if (this.cache) {
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}
@@ -120,7 +120,7 @@ class ExternalAPI {
}
const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) {
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}
@@ -164,7 +164,7 @@ class ExternalAPI {
}
const resData = await this.getDataFromResponse(response);
if (this.cache && ttl !== 0) {
if (this.cache) {
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
}

View File

@@ -157,13 +157,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`,
{
includeEpisode: 'true',
},
0
);
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
includeEpisode: 'true',
});
return data.records;
} catch (e) {
@@ -197,24 +193,15 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
}
};
async refreshMonitoredDownloads(): Promise<void> {
await this.runCommand('RefreshMonitoredDownloads', {});
}
protected async runCommand(
commandName: string,
options: Record<string, unknown>
): Promise<void> {
try {
await this.post(
`/command`,
{
name: commandName,
...options,
},
{},
0
);
await this.post(`/command`, {
name: commandName,
...options,
});
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
}

View File

@@ -32,6 +32,7 @@ import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import type { Store } from 'express-session';
import session from 'express-session';
import helmet from 'helmet';
import next from 'next';
import dns from 'node:dns';
import net from 'node:net';
@@ -159,6 +160,28 @@ app
});
}
// Setup Content-Security-Policy
server.use(
helmet.contentSecurityPolicy({
useDefaults: false,
directives: {
'default-src': ["'self'", "'unsafe-inline'"],
'script-src': [
"'self'",
"'unsafe-inline'",
...(dev ? ["'unsafe-eval'"] : []),
],
'img-src': ["'self'", "'unsafe-inline'", 'data:', 'blob:', '*'],
'frame-ancestors': [
"'self'",
...(settings.main.cspFrameAncestorDomains
? [settings.main.cspFrameAncestorDomains]
: []),
],
},
})
);
// Set up sessions
const sessionRespository = getRepository(Session);
server.use(
@@ -170,12 +193,16 @@ app
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
secure: 'auto',
sameSite: settings.main.csrfProtection
? 'strict'
: settings.main.cspFrameAncestorDomains
? 'none'
: 'lax',
secure: settings.main.cspFrameAncestorDomains ? true : 'auto',
},
store: new TypeormStore({
cleanupLimit: 2,
ttl: 60 * 60 * 24 * 30,
ttl: 1000 * 60 * 60 * 24 * 30,
}).connect(sessionRespository) as Store,
})
);

View File

@@ -85,7 +85,6 @@ class DownloadTracker {
});
try {
await radarr.refreshMonitoredDownloads();
const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({
@@ -163,7 +162,6 @@ class DownloadTracker {
});
try {
await sonarr.refreshMonitoredDownloads();
const queueItems = await sonarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({

View File

@@ -104,6 +104,7 @@ export interface MainSettings {
applicationTitle: string;
applicationUrl: string;
csrfProtection: boolean;
cspFrameAncestorDomains: string;
cacheImages: boolean;
defaultPermissions: number;
defaultQuotas: {
@@ -310,6 +311,7 @@ class Settings {
applicationTitle: 'Jellyseerr',
applicationUrl: '',
csrfProtection: false,
cspFrameAncestorDomains: '',
cacheImages: false,
defaultPermissions: Permission.REQUEST,
defaultQuotas: {

View File

@@ -13,7 +13,8 @@ class RestartFlag {
return (
this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy
this.settings.trustProxy !== settings.trustProxy ||
this.settings.cspFrameAncestorDomains !== settings.cspFrameAncestorDomains
);
}
}

View File

@@ -7,7 +7,6 @@ import {
CogIcon,
EllipsisHorizontalIcon,
ExclamationTriangleIcon,
EyeSlashIcon,
FilmIcon,
SparklesIcon,
TvIcon,
@@ -17,7 +16,6 @@ import {
ClockIcon as FilledClockIcon,
CogIcon as FilledCogIcon,
ExclamationTriangleIcon as FilledExclamationTriangleIcon,
EyeSlashIcon as FilledEyeSlashIcon,
FilmIcon as FilledFilmIcon,
SparklesIcon as FilledSparklesIcon,
TvIcon as FilledTvIcon,
@@ -86,18 +84,6 @@ const MobileMenu = () => {
svgIconSelected: <FilledClockIcon className="h-6 w-6" />,
activeRegExp: /^\/requests/,
},
{
href: '/blacklist',
content: intl.formatMessage(menuMessages.blacklist),
svgIcon: <EyeSlashIcon className="h-6 w-6" />,
svgIconSelected: <FilledEyeSlashIcon className="h-6 w-6" />,
activeRegExp: /^\/blacklist/,
requiredPermission: [
Permission.MANAGE_BLACKLIST,
Permission.VIEW_BLACKLIST,
],
permissionType: 'or',
},
{
href: '/issues',
content: intl.formatMessage(menuMessages.issues),

View File

@@ -42,7 +42,6 @@ const messages = defineMessages('components.RequestList.RequestItem', {
tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID',
unknowntitle: 'Unknown Title',
removearr: 'Remove from {arr}',
profileName: 'Profile',
});
@@ -342,18 +341,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
revalidateList();
};
const deleteMediaFile = async () => {
if (request.media) {
await fetch(`/api/v1/media/${request.media.id}/file`, {
method: 'DELETE',
});
await fetch(`/api/v1/media/${request.media.id}`, {
method: 'DELETE',
});
revalidateList();
}
};
const retryRequest = async () => {
setRetrying(true);
@@ -679,28 +666,14 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<>
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
<ConfirmButton
onClick={() => deleteMediaFile()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>
{intl.formatMessage(messages.removearr, {
arr: request.type === 'movie' ? 'Radarr' : 'Sonarr',
})}
</span>
</ConfirmButton>
</>
<ConfirmButton
onClick={() => deleteRequest()}
confirmText={intl.formatMessage(globalMessages.areyousure)}
className="w-full"
>
<TrashIcon />
<span>{intl.formatMessage(messages.deleterequest)}</span>
</ConfirmButton>
)}
{requestData.status === MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (

View File

@@ -44,6 +44,9 @@ const messages = defineMessages('components.Settings.SettingsMain', {
csrfProtectionTip: 'Set external API access to read-only (requires HTTPS)',
csrfProtectionHoverTip:
'Do NOT enable this setting unless you understand what you are doing!',
cspFrameAncestorDomains: 'Frame-Ancestor Domains',
cspFrameAncestorDomainsTip:
'Domains to allow embedding Jellyseer as iframe, object or embed. Incompatible with CSRF-Protection',
cacheImages: 'Enable Image Caching',
cacheImagesTip:
'Cache externally sourced images (requires a significant amount of disk space)',
@@ -130,6 +133,7 @@ const SettingsMain = () => {
applicationTitle: data?.applicationTitle,
applicationUrl: data?.applicationUrl,
csrfProtection: data?.csrfProtection,
cspFrameAncestorDomains: data?.cspFrameAncestorDomains,
hideAvailable: data?.hideAvailable,
locale: data?.locale ?? 'en',
region: data?.region,
@@ -151,6 +155,7 @@ const SettingsMain = () => {
applicationTitle: values.applicationTitle,
applicationUrl: values.applicationUrl,
csrfProtection: values.csrfProtection,
cspFrameAncestorDomains: values.cspFrameAncestorDomains,
hideAvailable: values.hideAvailable,
locale: values.locale,
region: values.region,
@@ -318,6 +323,31 @@ const SettingsMain = () => {
</Tooltip>
</div>
</div>
<div className="form-row">
<label
htmlFor="cspFrameAncestorDomains"
className="text-label"
>
<span className="mr-2">
{intl.formatMessage(messages.cspFrameAncestorDomains)}
</span>
<SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.cspFrameAncestorDomainsTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="cspFrameAncestorDomains"
name="cspFrameAncestorDomains"
type="text"
disabled={values.csrfProtection}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="cacheImages" className="checkbox-label">
<span className="mr-2">

View File

@@ -298,7 +298,6 @@
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
"components.ManageSlideOver.removearr": "Remove from {arr}",
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
@@ -874,6 +873,8 @@
"components.Settings.SettingsMain.applicationurl": "Application URL",
"components.Settings.SettingsMain.cacheImages": "Enable Image Caching",
"components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
"components.Settings.SettingsMain.cspFrameAncestorDomains": "Frame-Ancestor Domains",
"components.Settings.SettingsMain.cspFrameAncestorDomainsTip": "Domains to allow embedding Jellyseer as iframe, object or embed. Incompatible with CSRF-Protection",
"components.Settings.SettingsMain.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsMain.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
@@ -1084,7 +1085,7 @@
"components.Setup.finishing": "Finishing…",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
"components.Setup.signin": "Sign in to your account",
"components.Setup.signin": "Sign In",
"components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",

View File

@@ -17,11 +17,13 @@ import { MediaServerType } from '@server/constants/server';
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
import type { AppInitialProps, AppProps } from 'next/app';
import App from 'next/app';
import { Inter } from 'next/font/google';
import Head from 'next/head';
import { useEffect, useState } from 'react';
import { IntlProvider } from 'react-intl';
import { ToastProvider } from 'react-toast-notifications';
import { SWRConfig } from 'swr';
const inter = Inter({ subsets: ['latin'] });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadLocaleData = (locale: AvailableLocale): Promise<any> => {
@@ -136,47 +138,49 @@ const CoreApp: Omit<NextAppComponentType, 'origGetInitialProps'> = ({
}
return (
<SWRConfig
value={{
fetcher: async (resource, init) => {
const res = await fetch(resource, init);
if (!res.ok) throw new Error();
return await res.json();
},
fallback: {
'/api/v1/auth/me': user,
},
}}
>
<LanguageContext.Provider value={{ locale: currentLocale, setLocale }}>
<IntlProvider
locale={currentLocale}
defaultLocale="en"
messages={loadedMessages}
>
<LoadingBar />
<SettingsProvider currentSettings={currentSettings}>
<InteractionProvider>
<ToastProvider components={{ Toast, ToastContainer }}>
<Head>
<title>{currentSettings.applicationTitle}</title>
<meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width"
></meta>
<PWAHeader
applicationTitle={currentSettings.applicationTitle}
/>
</Head>
<StatusChecker />
<ServiceWorkerSetup />
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>
</SettingsProvider>
</IntlProvider>
</LanguageContext.Provider>
</SWRConfig>
<main className={inter.className}>
<SWRConfig
value={{
fetcher: async (resource, init) => {
const res = await fetch(resource, init);
if (!res.ok) throw new Error();
return await res.json();
},
fallback: {
'/api/v1/auth/me': user,
},
}}
>
<LanguageContext.Provider value={{ locale: currentLocale, setLocale }}>
<IntlProvider
locale={currentLocale}
defaultLocale="en"
messages={loadedMessages}
>
<LoadingBar />
<SettingsProvider currentSettings={currentSettings}>
<InteractionProvider>
<ToastProvider components={{ Toast, ToastContainer }}>
<Head>
<title>{currentSettings.applicationTitle}</title>
<meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width"
></meta>
<PWAHeader
applicationTitle={currentSettings.applicationTitle}
/>
</Head>
<StatusChecker />
<ServiceWorkerSetup />
<UserContext initialUser={user}>{component}</UserContext>
</ToastProvider>
</InteractionProvider>
</SettingsProvider>
</IntlProvider>
</LanguageContext.Provider>
</SWRConfig>
</main>
);
};

View File

@@ -13,13 +13,7 @@ class MyDocument extends Document {
render(): JSX.Element {
return (
<Html>
<Head>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
/>
</Head>
<Head></Head>
<body>
<Main />
<NextScript />

View File

@@ -53,6 +53,10 @@
}
@layer components {
*:disabled {
opacity: 0.7;
}
.searchbar {
padding-top: env(safe-area-inset-top);
height: calc(4rem + env(safe-area-inset-top));