Compare commits
2 Commits
preview-de
...
preview-fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75651f63ad | ||
|
|
edfd80444c |
@@ -7,6 +7,7 @@
|
||||
"applicationTitle": "Overseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cspFrameAncestorDomains": "",
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
|
||||
@@ -10,7 +10,6 @@ module.exports = {
|
||||
remotePatterns: [
|
||||
{ hostname: 'gravatar.com' },
|
||||
{ hostname: 'image.tmdb.org' },
|
||||
{ hostname: '*', protocol: 'https' },
|
||||
],
|
||||
},
|
||||
webpack(config) {
|
||||
|
||||
@@ -168,6 +168,9 @@ components:
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
cspFrameAncestorDomains:
|
||||
type: string
|
||||
example: 'example.com'
|
||||
hideAvailable:
|
||||
type: boolean
|
||||
example: false
|
||||
@@ -2790,6 +2793,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:
|
||||
|
||||
@@ -61,7 +61,9 @@
|
||||
"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",
|
||||
"node-cache": "5.1.2",
|
||||
"node-gyp": "9.3.1",
|
||||
@@ -119,6 +121,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",
|
||||
|
||||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -95,9 +95,15 @@ 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
|
||||
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 +270,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 +2857,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==}
|
||||
|
||||
@@ -5283,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==}
|
||||
|
||||
@@ -10836,7 +10852,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 +10927,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 +12342,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 +12686,8 @@ snapshots:
|
||||
|
||||
'@types/mime@1.3.5': {}
|
||||
|
||||
'@types/mime@3.0.4': {}
|
||||
|
||||
'@types/minimatch@3.0.5': {}
|
||||
|
||||
'@types/minimist@1.2.5': {}
|
||||
@@ -12887,7 +12905,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
|
||||
@@ -15584,6 +15602,8 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
helmet@7.1.0: {}
|
||||
|
||||
hermes-estree@0.19.1: {}
|
||||
|
||||
hermes-estree@0.20.1: {}
|
||||
@@ -17269,7 +17289,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 +17368,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: {}
|
||||
|
||||
@@ -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';
|
||||
@@ -31,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';
|
||||
@@ -158,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(
|
||||
@@ -169,8 +193,12 @@ 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,
|
||||
@@ -202,6 +230,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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -227,6 +227,9 @@ export const startJobs = (): void => {
|
||||
});
|
||||
// Clean TMDB image cache
|
||||
ImageProxy.clearCache('tmdb');
|
||||
|
||||
// Clean users avatar image cache
|
||||
ImageProxy.clearCache('avatar');
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
try {
|
||||
const files = await promises.readdir(cacheDirectory);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(cacheDirectory, file);
|
||||
const stat = await promises.lstat(filePath);
|
||||
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);
|
||||
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();
|
||||
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;
|
||||
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<number> {
|
||||
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 (await Promise.all(paths))
|
||||
.flat(Infinity)
|
||||
.reduce((i, size) => i + size, 0);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
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<ImageResponse | null> {
|
||||
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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
32
server/routes/avatarproxy.ts
Normal file
32
server/routes/avatarproxy.ts
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = ({
|
||||
</Modal>
|
||||
</Transition>
|
||||
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
|
||||
<Image
|
||||
src={comment.user.avatar}
|
||||
<CachedImage
|
||||
src={`${comment.user.avatar}`}
|
||||
alt=""
|
||||
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
width={40}
|
||||
|
||||
@@ -28,7 +28,6 @@ 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 Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -287,10 +286,10 @@ const IssueDetails = () => {
|
||||
}
|
||||
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
|
||||
>
|
||||
<Image
|
||||
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"
|
||||
src={issueData.createdBy.avatar}
|
||||
<CachedImage
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<Image
|
||||
src={issue.createdBy.avatar}
|
||||
<CachedImage
|
||||
src={'/avatarproxy/' + issue.createdBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm ml-1.5 object-cover"
|
||||
width={20}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||
import Image from 'next/image';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
import { forwardRef, Fragment } from 'react';
|
||||
@@ -56,9 +56,9 @@ const UserDropdown = () => {
|
||||
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"
|
||||
>
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user?.avatar || ''}
|
||||
src={user ? user.avatar : ''}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
@@ -79,9 +79,9 @@ const UserDropdown = () => {
|
||||
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
|
||||
<div className="flex flex-col space-y-4 px-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
|
||||
src={user?.avatar || ''}
|
||||
src={user ? user.avatar : ''}
|
||||
alt=""
|
||||
width={40}
|
||||
height={40}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import BlacklistBlock from '@app/components/BlacklistBlock';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import SlideOver from '@app/components/Common/SlideOver';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
@@ -27,7 +28,6 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
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 { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
@@ -368,7 +368,7 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<Image
|
||||
<CachedImage
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
@@ -529,7 +529,7 @@ const ManageSlideOver = ({
|
||||
key={`watch-user-${user.id}`}
|
||||
content={user.displayName}
|
||||
>
|
||||
<Image
|
||||
<CachedImage
|
||||
src={user.avatar}
|
||||
alt={user.displayName}
|
||||
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
|
||||
|
||||
@@ -22,7 +22,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
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 { useEffect, useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
@@ -116,7 +115,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<Image
|
||||
<CachedImage
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -390,7 +389,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
className="group flex items-center"
|
||||
>
|
||||
<span className="avatar-sm">
|
||||
<Image
|
||||
<CachedImage
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
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 { useState } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
@@ -190,7 +189,7 @@ const RequestItemError = ({
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
<CachedImage
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -249,7 +248,7 @@ const RequestItemError = ({
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
<CachedImage
|
||||
src={requestData.modifiedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -557,7 +556,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
<CachedImage
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
@@ -616,7 +615,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
className="group flex items-center truncate"
|
||||
>
|
||||
<span className="avatar-sm ml-1.5">
|
||||
<Image
|
||||
<CachedImage
|
||||
src={requestData.requestedBy.avatar}
|
||||
alt=""
|
||||
className="avatar-sm object-cover"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import type { User } from '@app/hooks/useUser';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
@@ -14,7 +15,6 @@ import type {
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import { hasPermission } from '@server/lib/permissions';
|
||||
import { isEqual } from 'lodash';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import Select from 'react-select';
|
||||
@@ -561,7 +561,7 @@ const AdvancedRequester = ({
|
||||
<span className="inline-block w-full rounded-md shadow-sm">
|
||||
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
|
||||
<span className="flex items-center">
|
||||
<Image
|
||||
<CachedImage
|
||||
src={selectedUser.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
@@ -613,7 +613,7 @@ const AdvancedRequester = ({
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} flex items-center`}
|
||||
>
|
||||
<Image
|
||||
<CachedImage
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
|
||||
|
||||
@@ -82,6 +82,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
||||
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
|
||||
imagecachecount: 'Images Cached',
|
||||
imagecachesize: 'Total Cache Size',
|
||||
usersavatars: "Users' Avatars",
|
||||
}
|
||||
);
|
||||
|
||||
@@ -573,6 +574,19 @@ const SettingsJobs = () => {
|
||||
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
<tr>
|
||||
<Table.TD>
|
||||
{intl.formatMessage(messages.usersavatars)} (avatar)
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{intl.formatNumber(
|
||||
cacheData?.imageCache.avatar.imageCount ?? 0
|
||||
)}
|
||||
</Table.TD>
|
||||
<Table.TD>
|
||||
{formatBytes(cacheData?.imageCache.avatar.size ?? 0)}
|
||||
</Table.TD>
|
||||
</tr>
|
||||
</Table.TBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<JellyfinImportProps> = ({
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-10 w-10 flex-shrink-0 rounded-full"
|
||||
src={user.thumb}
|
||||
alt=""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Header from '@app/components/Common/Header';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
@@ -28,7 +29,6 @@ import { MediaServerType } from '@server/constants/server';
|
||||
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import { hasPermission } from '@server/lib/permissions';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -633,7 +633,7 @@ const UserList = () => {
|
||||
href={`/users/${user.id}`}
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import type { User } from '@app/hooks/useUser';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -42,7 +42,7 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
|
||||
<div className="flex items-end justify-items-end space-x-5">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Image
|
||||
<CachedImage
|
||||
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
|
||||
src={user.avatar}
|
||||
alt=""
|
||||
|
||||
@@ -849,6 +849,7 @@
|
||||
"components.Settings.SettingsJobsCache.runnow": "Run Now",
|
||||
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
|
||||
"components.Settings.SettingsJobsCache.unknownJob": "Unknown Job",
|
||||
"components.Settings.SettingsJobsCache.usersavatars": "Users' Avatars",
|
||||
"components.Settings.SettingsLogs.copiedLogMessage": "Copied log message to clipboard.",
|
||||
"components.Settings.SettingsLogs.copyToClipboard": "Copy to Clipboard",
|
||||
"components.Settings.SettingsLogs.extraData": "Additional Data",
|
||||
@@ -872,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)",
|
||||
@@ -968,6 +971,7 @@
|
||||
"components.Settings.address": "Address",
|
||||
"components.Settings.addsonarr": "Add Sonarr Server",
|
||||
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
||||
"components.Settings.apiKey": "API key",
|
||||
"components.Settings.cancelscan": "Cancel Scan",
|
||||
"components.Settings.copied": "Copied API key to clipboard.",
|
||||
"components.Settings.currentlibrary": "Current Library: {name}",
|
||||
@@ -1028,6 +1032,7 @@
|
||||
"components.Settings.save": "Save Changes",
|
||||
"components.Settings.saving": "Saving…",
|
||||
"components.Settings.scan": "Sync Libraries",
|
||||
"components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
|
||||
"components.Settings.scanning": "Syncing…",
|
||||
"components.Settings.serverLocal": "local",
|
||||
"components.Settings.serverRemote": "remote",
|
||||
@@ -1048,6 +1053,7 @@
|
||||
"components.Settings.tautulliSettings": "Tautulli Settings",
|
||||
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.",
|
||||
"components.Settings.timeout": "Timeout",
|
||||
"components.Settings.tip": "Tip",
|
||||
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
|
||||
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
|
||||
"components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!",
|
||||
@@ -1077,7 +1083,6 @@
|
||||
"components.Setup.continue": "Continue",
|
||||
"components.Setup.finish": "Finish Setup",
|
||||
"components.Setup.finishing": "Finishing…",
|
||||
"components.Setup.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
|
||||
"components.Setup.servertype": "Choose Server Type",
|
||||
"components.Setup.setup": "Setup",
|
||||
"components.Setup.signin": "Sign In",
|
||||
@@ -1086,7 +1091,6 @@
|
||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||
"components.Setup.signinWithPlex": "Enter your Plex details",
|
||||
"components.Setup.subtitle": "Get started by choosing your media server",
|
||||
"components.Setup.tip": "Tip",
|
||||
"components.Setup.welcome": "Welcome to Jellyseerr",
|
||||
"components.StatusBadge.managemedia": "Manage {mediaType}",
|
||||
"components.StatusBadge.openinarr": "Open in {arr}",
|
||||
@@ -1231,6 +1235,7 @@
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
||||
@@ -1310,6 +1315,7 @@
|
||||
"components.UserProfile.seriesrequest": "Series Requests",
|
||||
"components.UserProfile.totalrequests": "Total Requests",
|
||||
"components.UserProfile.unlimited": "Unlimited",
|
||||
"i18n.addToBlacklist": "Add to Blacklist",
|
||||
"i18n.advanced": "Advanced",
|
||||
"i18n.all": "All",
|
||||
"i18n.approve": "Approve",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user