diff --git a/.gitignore b/.gitignore index 9a8925ab0..c417acb09 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ yarn-error.log* # database config/db/*.sqlite3* config/settings.json +config/settings.old.json # logs config/logs/*.log* diff --git a/docs/getting-started/aur.mdx b/docs/getting-started/aur.mdx index a67a0b24b..025118c8f 100644 --- a/docs/getting-started/aur.mdx +++ b/docs/getting-started/aur.mdx @@ -6,6 +6,10 @@ sidebar_position: 4 # AUR (Arch User Repository) +:::note Disclaimer +This AUR package is not maintained by us but by a third party. Please refer to the maintainer for any issues. +::: + :::info This method is not recommended for most users. It is intended for advanced users who are using Arch Linux or an Arch-based distribution. ::: diff --git a/overseerr-api.yml b/overseerr-api.yml index 1f15456f6..00a594922 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1994,6 +1994,9 @@ paths: appDataPath: type: string example: /app/config + appDataPermissions: + type: boolean + example: true /settings/main: get: summary: Get main settings diff --git a/package.json b/package.json index 9ce9330f8..f6af746db 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,8 @@ "sqlite3": "5.1.4", "swagger-ui-express": "4.6.2", "swr": "2.2.5", - "typeorm": "0.3.12", + "typeorm": "0.3.11", + "undici": "^6.20.1", "web-push": "3.5.0", "winston": "3.8.2", "winston-daily-rotate-file": "4.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7391a775a..8b68e8b5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 2.11.0 connect-typeorm: specifier: 1.1.4 - version: 1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))) + version: 1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))) cookie-parser: specifier: 1.4.6 version: 1.4.6 @@ -192,8 +192,11 @@ importers: specifier: 2.2.5 version: 2.2.5(react@18.3.1) typeorm: - specifier: 0.3.12 - version: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) + specifier: 0.3.11 + version: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) + undici: + specifier: ^6.20.1 + version: 6.20.1 web-push: specifier: 3.5.0 version: 3.5.0 @@ -4264,10 +4267,6 @@ packages: resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==} engines: {node: '>=0.11'} - date-fns@2.30.0: - resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} - engines: {node: '>=0.11'} - dateformat@3.0.3: resolution: {integrity: sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==} @@ -5389,8 +5388,8 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - https-proxy-agent@7.0.4: - resolution: {integrity: sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==} + https-proxy-agent@7.0.5: + resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} engines: {node: '>= 14'} human-signals@1.1.1: @@ -6554,11 +6553,6 @@ packages: engines: {node: '>=10'} hasBin: true - mkdirp@2.1.6: - resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} - engines: {node: '>=10'} - hasBin: true - modify-values@1.0.1: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==} engines: {node: '>=0.10.0'} @@ -7730,9 +7724,6 @@ packages: reflect-metadata@0.1.13: resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==} - reflect-metadata@0.1.14: - resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} - reflect.getprototypeof@1.0.6: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} @@ -8670,8 +8661,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typeorm@0.3.12: - resolution: {integrity: sha512-sYSxBmCf1nJLLTcYtwqZ+lQIRtLPyUoO93rHTOKk9vJCyT4UfRtU7oRsJvfvKP3nnZTD1hzz2SEy2zwPEN6OyA==} + typeorm@0.3.11: + resolution: {integrity: sha512-pzdOyWbVuz/z8Ww6gqvBW4nylsM0KLdUCDExr2gR20/x1khGSVxQkjNV/3YqliG90jrWzrknYbYscpk8yxFJVg==} engines: {node: '>= 12.9.0'} hasBin: true peerDependencies: @@ -8682,7 +8673,7 @@ packages: ioredis: ^5.0.4 mongodb: ^3.6.0 mssql: ^7.3.0 - mysql2: ^2.2.5 || ^3.0.1 + mysql2: ^2.2.5 oracledb: ^5.1.0 pg: ^8.5.1 pg-native: ^3.0.0 @@ -8768,6 +8759,10 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici@6.20.1: + resolution: {integrity: sha512-AjQF1QsmqfJys+LXfGTNum+qw4S88CojRInG/6t31W/1fk6G59s92bnAvGz5Cmur+kQv2SURXEvvudLmbrE8QA==} + engines: {node: '>=18.17'} + unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -12310,7 +12305,7 @@ snapshots: fs-extra: 11.2.0 globby: 11.1.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.4 + https-proxy-agent: 7.0.5 issue-parser: 6.0.0 lodash: 4.17.21 mime: 3.0.0 @@ -13824,13 +13819,13 @@ snapshots: ini: 1.3.8 proto-list: 1.2.4 - connect-typeorm@1.1.4(typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))): + connect-typeorm@1.1.4(typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5))): dependencies: '@types/debug': 0.0.31 '@types/express-session': 1.17.6 debug: 4.3.5(supports-color@8.1.1) express-session: 1.18.0 - typeorm: 0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) + typeorm: 0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)) transitivePeerDependencies: - supports-color @@ -14181,10 +14176,6 @@ snapshots: date-fns@2.29.3: {} - date-fns@2.30.0: - dependencies: - '@babel/runtime': 7.24.7 - dateformat@3.0.3: {} dayjs@1.11.11: {} @@ -15739,7 +15730,7 @@ snapshots: transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.4: + https-proxy-agent@7.0.5: dependencies: agent-base: 7.1.1 debug: 4.3.5(supports-color@8.1.1) @@ -17149,8 +17140,6 @@ snapshots: mkdirp@1.0.4: {} - mkdirp@2.1.6: {} - modify-values@1.0.1: {} moment@2.30.1: {} @@ -18372,8 +18361,6 @@ snapshots: reflect-metadata@0.1.13: {} - reflect-metadata@0.1.14: {} - reflect.getprototypeof@1.0.6: dependencies: call-bind: 1.0.7 @@ -19431,23 +19418,23 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.12(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)): + typeorm@0.3.11(sqlite3@5.1.4(encoding@0.1.13))(ts-node@10.9.1(@swc/core@1.6.5(@swc/helpers@0.5.11))(@types/node@20.14.8)(typescript@4.9.5)): dependencies: '@sqltools/formatter': 1.2.5 app-root-path: 3.1.0 buffer: 6.0.3 chalk: 4.1.2 cli-highlight: 2.1.11 - date-fns: 2.30.0 + date-fns: 2.29.3 debug: 4.3.5(supports-color@8.1.1) dotenv: 16.4.5 - glob: 8.1.0 + glob: 7.2.3 js-yaml: 4.1.0 - mkdirp: 2.1.6 - reflect-metadata: 0.1.14 + mkdirp: 1.0.4 + reflect-metadata: 0.1.13 sha.js: 2.4.11 tslib: 2.6.3 - uuid: 9.0.1 + uuid: 8.3.2 xml2js: 0.4.23 yargs: 17.7.2 optionalDependencies: @@ -19486,6 +19473,8 @@ snapshots: undici-types@5.26.5: {} + undici@6.20.1: {} + unicode-canonical-property-names-ecmascript@2.0.0: {} unicode-emoji-utils@1.2.0: diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index e5eb91286..8df9e95d6 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -32,14 +32,28 @@ class ExternalAPI { this.fetch = fetch; } - this.baseUrl = baseUrl; - this.params = params; + const url = new URL(baseUrl); + this.defaultHeaders = { 'Content-Type': 'application/json', Accept: 'application/json', + ...((url.username || url.password) && { + Authorization: `Basic ${Buffer.from( + `${url.username}:${url.password}` + ).toString('base64')}`, + }), ...options.headers, }; + if (url.username || url.password) { + url.username = ''; + url.password = ''; + baseUrl = url.toString(); + } + + this.baseUrl = baseUrl; + this.params = params; + this.cache = options.nodeCache; } diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index f65503477..7b45cdaf7 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -410,7 +410,7 @@ class JellyfinAPI extends ExternalAPI { ).AccessToken; } catch (e) { logger.error( - `Something went wrong while creating an API key the Jellyfin server: ${e.message}`, + `Something went wrong while creating an API key from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API' } ); diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index f6b8f3cb0..10d5d1d2a 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -180,7 +180,7 @@ class PlexAPI { settings.plex.libraries = []; } - settings.save(); + await settings.save(); } public async getLibraryContents( diff --git a/server/index.ts b/server/index.ts index 965903618..cd65d566a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -21,7 +21,9 @@ import clearCookies from '@server/middleware/clearcookies'; import routes from '@server/routes'; import avatarproxy from '@server/routes/avatarproxy'; import imageproxy from '@server/routes/imageproxy'; +import { appDataPermissions } from '@server/utils/appDataVolume'; import { getAppVersion } from '@server/utils/appVersion'; +import createCustomProxyAgent from '@server/utils/customProxyAgent'; import restartFlag from '@server/utils/restartFlag'; import { getClientIp } from '@supercharge/request-ip'; import { TypeormStore } from 'connect-typeorm/out'; @@ -51,6 +53,12 @@ const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); +if (!appDataPermissions()) { + logger.error( + 'Something went wrong while checking config folder! Please ensure the config folder is set up properly.\nhttps://docs.jellyseerr.dev/getting-started' + ); +} + app .prepare() .then(async () => { @@ -67,6 +75,11 @@ app const settings = await getSettings().load(); restartFlag.initializeSettings(settings.main); + // Register HTTP proxy + if (settings.main.proxy.enabled) { + await createCustomProxyAgent(settings.main.proxy); + } + // Migrate library types if ( settings.plex.libraries.length > 1 && diff --git a/server/lib/imageproxy.ts b/server/lib/imageproxy.ts index badfe94f2..04e320a0b 100644 --- a/server/lib/imageproxy.ts +++ b/server/lib/imageproxy.ts @@ -135,6 +135,7 @@ class ImageProxy { private cacheVersion; private key; private baseUrl; + private headers: HeadersInit | null = null; constructor( key: string, @@ -142,6 +143,7 @@ class ImageProxy { options: { cacheVersion?: number; rateLimitOptions?: RateLimitOptions; + headers?: HeadersInit; } = {} ) { this.cacheVersion = options.cacheVersion ?? 1; @@ -155,9 +157,13 @@ class ImageProxy { } else { this.fetch = fetch; } + this.headers = options.headers || null; } - public async getImage(path: string): Promise { + public async getImage( + path: string, + fallbackPath?: string + ): Promise { const cacheKey = this.getCacheKey(path); const imageResponse = await this.get(cacheKey); @@ -166,7 +172,11 @@ class ImageProxy { const newImage = await this.set(path, cacheKey); if (!newImage) { - throw new Error('Failed to load image'); + if (fallbackPath) { + return await this.getImage(fallbackPath); + } else { + throw new Error('Failed to load image'); + } } return newImage; @@ -247,7 +257,12 @@ class ImageProxy { : '/' : '') + (path.startsWith('/') ? path.slice(1) : path); - const response = await this.fetch(href); + const response = await this.fetch(href, { + headers: this.headers || undefined, + }); + if (!response.ok) { + return null; + } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index 2c0433bfd..5f722b6b8 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -129,7 +129,7 @@ class PlexScanner }); settings.plex.libraries = newLibraries; - settings.save(); + await settings.save(); } } else { for (const library of this.libraries) { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 1a344e9ee..c109f9bd4 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -5,7 +5,7 @@ import { MediaServerType } from '@server/constants/server'; import { Permission } from '@server/lib/permissions'; import { runMigrations } from '@server/lib/settings/migrator'; import { randomUUID } from 'crypto'; -import fs from 'fs'; +import fs from 'fs/promises'; import { merge } from 'lodash'; import path from 'path'; import webpush from 'web-push'; @@ -102,6 +102,17 @@ interface Quota { quotaDays?: number; } +export interface ProxySettings { + enabled: boolean; + hostname: string; + port: number; + useSsl: boolean; + user: string; + password: string; + bypassFilter: string; + bypassLocalAddresses: boolean; +} + export interface MainSettings { apiKey: string; applicationTitle: string; @@ -122,6 +133,7 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; locale: string; + proxy: ProxySettings; } interface PublicSettings { @@ -329,6 +341,16 @@ class Settings { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, locale: 'en', + proxy: { + enabled: false, + hostname: '', + port: 8080, + useSsl: false, + user: '', + password: '', + bypassFilter: '', + bypassLocalAddresses: true, + }, }, plex: { name: '', @@ -484,10 +506,6 @@ class Settings { } get main(): MainSettings { - if (!this.data.main.apiKey) { - this.data.main.apiKey = this.generateApiKey(); - this.save(); - } return this.data.main; } @@ -597,29 +615,20 @@ class Settings { } get clientId(): string { - if (!this.data.clientId) { - this.data.clientId = randomUUID(); - this.save(); - } - return this.data.clientId; } get vapidPublic(): string { - this.generateVapidKeys(); - return this.data.vapidPublic; } get vapidPrivate(): string { - this.generateVapidKeys(); - return this.data.vapidPrivate; } - public regenerateApiKey(): MainSettings { + public async regenerateApiKey(): Promise { this.main.apiKey = this.generateApiKey(); - this.save(); + await this.save(); return this.main; } @@ -631,15 +640,6 @@ class Settings { } } - private generateVapidKeys(force = false): void { - if (!this.data.vapidPublic || !this.data.vapidPrivate || force) { - const vapidKeys = webpush.generateVAPIDKeys(); - this.data.vapidPrivate = vapidKeys.privateKey; - this.data.vapidPublic = vapidKeys.publicKey; - this.save(); - } - } - /** * Settings Load * @@ -654,30 +654,51 @@ class Settings { return this; } - if (!fs.existsSync(SETTINGS_PATH)) { - this.save(); + let data; + try { + data = await fs.readFile(SETTINGS_PATH, 'utf-8'); + } catch { + await this.save(); } - const data = fs.readFileSync(SETTINGS_PATH, 'utf-8'); if (data) { const parsedJson = JSON.parse(data); - this.data = await runMigrations(parsedJson, SETTINGS_PATH); - - this.data = merge(this.data, parsedJson); - - if (process.env.API_KEY) { - if (this.main.apiKey != process.env.API_KEY) { - this.main.apiKey = process.env.API_KEY; - } - } - - this.save(); + const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); + this.data = merge(this.data, migratedData); } + + // generate keys and ids if it's missing + let change = false; + if (!this.data.main.apiKey) { + this.data.main.apiKey = this.generateApiKey(); + change = true; + } else if (process.env.API_KEY) { + if (this.main.apiKey != process.env.API_KEY) { + this.main.apiKey = process.env.API_KEY; + } + } + if (!this.data.clientId) { + this.data.clientId = randomUUID(); + change = true; + } + if (!this.data.vapidPublic || !this.data.vapidPrivate) { + const vapidKeys = webpush.generateVAPIDKeys(); + this.data.vapidPrivate = vapidKeys.privateKey; + this.data.vapidPublic = vapidKeys.publicKey; + change = true; + } + if (change) { + await this.save(); + } + return this; } - public save(): void { - fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' ')); + public async save(): Promise { + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify(this.data, undefined, ' ') + ); } } diff --git a/server/lib/settings/migrations/0001_migrate_hostname.ts b/server/lib/settings/migrations/0001_migrate_hostname.ts index c514ac2db..ddc8211cf 100644 --- a/server/lib/settings/migrations/0001_migrate_hostname.ts +++ b/server/lib/settings/migrations/0001_migrate_hostname.ts @@ -1,15 +1,14 @@ import type { AllSettings } from '@server/lib/settings'; const migrateHostname = (settings: any): AllSettings => { - const oldJellyfinSettings = settings.jellyfin; - if (oldJellyfinSettings && oldJellyfinSettings.hostname) { - const { hostname } = oldJellyfinSettings; + if (settings.jellyfin?.hostname) { + const { hostname } = settings.jellyfin; const protocolMatch = hostname.match(/^(https?):\/\//i); const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https'; const remainingUrl = hostname.replace(/^(https?):\/\//i, ''); const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/); - delete oldJellyfinSettings.hostname; + delete settings.jellyfin.hostname; if (urlMatch) { const [, ip, , port, urlBase] = urlMatch; settings.jellyfin = { @@ -21,9 +20,7 @@ const migrateHostname = (settings: any): AllSettings => { }; } } - if (settings.jellyfin && settings.jellyfin.hostname) { - delete settings.jellyfin.hostname; - } + return settings; }; diff --git a/server/lib/settings/migrations/0002_migrate_apitokens.ts b/server/lib/settings/migrations/0002_migrate_apitokens.ts index 46340433b..0149c3e37 100644 --- a/server/lib/settings/migrations/0002_migrate_apitokens.ts +++ b/server/lib/settings/migrations/0002_migrate_apitokens.ts @@ -27,8 +27,14 @@ const migrateApiTokens = async (settings: any): Promise => { admin.jellyfinDeviceId ); jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); - const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); - settings.jellyfin.apiKey = apiKey; + try { + const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); + settings.jellyfin.apiKey = apiKey; + } catch { + throw new Error( + "Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue." + ); + } } return settings; }; diff --git a/server/lib/settings/migrator.ts b/server/lib/settings/migrator.ts index 002e5516d..801140000 100644 --- a/server/lib/settings/migrator.ts +++ b/server/lib/settings/migrator.ts @@ -1,7 +1,6 @@ -/* eslint-disable no-console */ import type { AllSettings } from '@server/lib/settings'; import logger from '@server/logger'; -import fs from 'fs'; +import fs from 'fs/promises'; import path from 'path'; const migrationsDir = path.join(__dirname, 'migrations'); @@ -10,19 +9,56 @@ export const runMigrations = async ( settings: AllSettings, SETTINGS_PATH: string ): Promise => { - const migrations = fs - .readdirSync(migrationsDir) - .filter((file) => file.endsWith('.js') || file.endsWith('.ts')) - // eslint-disable-next-line @typescript-eslint/no-var-requires - .map((file) => require(path.join(migrationsDir, file)).default); - let migrated = settings; try { + // we read old backup and create a backup of currents settings + const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json'); + let oldBackup: string | null = null; + try { + oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8'); + } catch { + /* empty */ + } + await fs.writeFile(BACKUP_PATH, JSON.stringify(settings, undefined, ' ')); + + const migrations = (await fs.readdir(migrationsDir)).filter( + (file) => file.endsWith('.js') || file.endsWith('.ts') + ); + const settingsBefore = JSON.stringify(migrated); for (const migration of migrations) { - migrated = await migration(migrated); + try { + logger.debug(`Checking migration '${migration}'...`, { + label: 'Settings Migrator', + }); + const { default: migrationFn } = await import( + path.join(migrationsDir, migration) + ); + const newSettings = await migrationFn(structuredClone(migrated)); + if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) { + logger.debug(`Migration '${migration}' has been applied.`, { + label: 'Settings Migrator', + }); + } + migrated = newSettings; + } catch (e) { + // we stop jellyseerr if the migration failed + logger.error( + `Error while running migration '${migration}': ${e.message}`, + { + label: 'Settings Migrator', + } + ); + logger.error( + 'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.', + { + label: 'Settings Migrator', + } + ); + process.exit(); + } } const settingsAfter = JSON.stringify(migrated); @@ -30,30 +66,33 @@ export const runMigrations = async ( if (settingsBefore !== settingsAfter) { // a migration occured // we check that the new config will be saved - fs.writeFileSync(SETTINGS_PATH, JSON.stringify(migrated, undefined, ' ')); - const fileSaved = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify(migrated, undefined, ' ') + ); + const fileSaved = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); if (JSON.stringify(fileSaved) !== settingsAfter) { // something went wrong while saving file throw new Error('Unable to save settings after migration.'); } + } else if (oldBackup) { + // no migration occured + // we save the old backup (to avoid settings.json and settings.old.json being the same) + await fs.writeFile(BACKUP_PATH, oldBackup.toString()); } } catch (e) { + // we stop jellyseerr if the migration failed logger.error( `Something went wrong while running settings migrations: ${e.message}`, - { label: 'Settings Migrator' } + { + label: 'Settings Migrator', + } ); - // we stop jellyseerr if the migration failed - console.log( - '====================================================================' - ); - console.log( - ' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS ' - ); - console.log( - ' Please check that your configuration folder is properly set up ' - ); - console.log( - '====================================================================' + logger.error( + 'A common cause for this issue is a permission error of your configuration folder.', + { + label: 'Settings Migrator', + } ); process.exit(); } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 560f04d57..d38ae2211 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,7 +6,6 @@ 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'; @@ -15,7 +14,6 @@ import { ApiError } from '@server/types/error'; import { getHostname } from '@server/utils/getHostname'; import * as EmailValidator from 'email-validator'; import { Router } from 'express'; -import gravatarUrl from 'gravatar-url'; import net from 'net'; const authRoutes = Router(); @@ -89,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => { }); settings.main.mediaServerType = MediaServerType.PLEX; - settings.save(); + await settings.save(); startJobs(); await userRepository.save(user); @@ -328,12 +326,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, - avatar: account.User.PrimaryImageTag - ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email || account.User.Name, { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${account.User.Id}`, userType: UserType.EMBY, }); @@ -347,12 +340,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinDeviceId: deviceId, jellyfinAuthToken: account.AccessToken, permissions: Permission.ADMIN, - avatar: account.User.PrimaryImageTag - ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email || account.User.Name, { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${account.User.Id}`, userType: UserType.JELLYFIN, }); @@ -378,7 +366,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.apiKey = apiKey; - settings.save(); + await settings.save(); startJobs(); await userRepository.save(user); @@ -401,27 +389,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUsername: account.User.Name, } ); - // Update the users avatar with their jellyfin profile pic (incase it changed) - if (account.User.PrimaryImageTag) { - const avatar = `/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 { - 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.avatar = `/avatarproxy/${account.User.Id}`; user.jellyfinUsername = account.User.Name; if (user.username === account.User.Name) { @@ -459,12 +427,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, permissions: settings.main.defaultPermissions, - avatar: account.User.PrimaryImageTag - ? `/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email || account.User.Name, { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${account.User.Id}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN diff --git a/server/routes/avatarproxy.ts b/server/routes/avatarproxy.ts index e6f6f3b54..2d72e2f19 100644 --- a/server/routes/avatarproxy.ts +++ b/server/routes/avatarproxy.ts @@ -1,21 +1,39 @@ import { MediaServerType } from '@server/constants/server'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; import ImageProxy from '@server/lib/imageproxy'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; +import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; +import gravatarUrl from 'gravatar-url'; const router = Router(); -const avatarImageProxy = new ImageProxy('avatar', ''); -// Proxy avatar images -router.get('/*', async (req, res) => { - let imagePath = ''; +let _avatarImageProxy: ImageProxy | null = null; +async function initAvatarImageProxy() { + if (!_avatarImageProxy) { + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + where: { id: 1 }, + select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'], + order: { id: 'ASC' }, + }); + const deviceId = admin?.jellyfinDeviceId; + const authToken = getSettings().jellyfin.apiKey; + _avatarImageProxy = new ImageProxy('avatar', '', { + headers: { + 'X-Emby-Authorization': `MediaBrowser Client="Jellyseerr", Device="Jellyseerr", DeviceId="${deviceId}", Version="${getAppVersion()}", Token="${authToken}"`, + }, + }); + } + return _avatarImageProxy; +} + +router.get('/:jellyfinUserId', async (req, res) => { try { - const jellyfinAvatar = req.url.match( - /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ - )?.[1]; - if (!jellyfinAvatar) { + if (!req.params.jellyfinUserId.match(/^[a-f0-9]{32}$/)) { const mediaServerType = getSettings().main.mediaServerType; throw new Error( `Provided URL is not ${ @@ -26,10 +44,28 @@ router.get('/*', async (req, res) => { ); } - const imageUrl = new URL(jellyfinAvatar, getHostname()); - imagePath = imageUrl.toString(); + const avatarImageCache = await initAvatarImageProxy(); - const imageData = await avatarImageProxy.getImage(imagePath); + const user = await getRepository(User).findOne({ + where: { jellyfinUserId: req.params.jellyfinUserId }, + }); + + const fallbackUrl = gravatarUrl(user?.email || 'none', { + default: 'mm', + size: 200, + }); + const jellyfinAvatarUrl = `${getHostname()}/UserImage?UserId=${ + req.params.jellyfinUserId + }`; + let imageData = await avatarImageCache.getImage( + jellyfinAvatarUrl, + fallbackUrl + ); + + if (imageData.meta.extension === 'json') { + // this is a 404 + imageData = await avatarImageCache.getImage(fallbackUrl); + } res.writeHead(200, { 'Content-Type': `image/${imageData.meta.extension}`, @@ -42,7 +78,6 @@ router.get('/*', async (req, res) => { res.end(imageData.imageBuffer); } catch (e) { logger.error('Failed to proxy avatar image', { - imagePath, errorMessage: e.message, }); } diff --git a/server/routes/index.ts b/server/routes/index.ts index 339a7249b..7eeb4fd6d 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -17,7 +17,11 @@ import { mapProductionCompany } from '@server/models/Movie'; import { mapNetwork } from '@server/models/Tv'; import settingsRoutes from '@server/routes/settings'; import watchlistRoutes from '@server/routes/watchlist'; -import { appDataPath, appDataStatus } from '@server/utils/appDataVolume'; +import { + appDataPath, + appDataPermissions, + appDataStatus, +} from '@server/utils/appDataVolume'; import { getAppVersion, getCommitTag } from '@server/utils/appVersion'; import restartFlag from '@server/utils/restartFlag'; import { isPerson } from '@server/utils/typeHelpers'; @@ -93,6 +97,7 @@ router.get('/status/appdata', (_req, res) => { return res.status(200).json({ appData: appDataStatus(), appDataPath: appDataPath(), + appDataPermissions: appDataPermissions(), }); }); diff --git a/server/routes/service.ts b/server/routes/service.ts index 6f9cac677..1c3d645ee 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -123,9 +123,13 @@ serviceRoutes.get<{ sonarrId: string }>( }); try { + const systemStatus = await sonarr.getSystemStatus(); + const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]); + const profiles = await sonarr.getProfiles(); const rootFolders = await sonarr.getRootFolders(); - const languageProfiles = await sonarr.getLanguageProfiles(); + const languageProfiles = + sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null; const tags = await sonarr.getTags(); return res.status(200).json({ diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 3f495fd69..f3bca630b 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -32,7 +32,6 @@ import { getHostname } from '@server/utils/getHostname'; import { Router } from 'express'; import rateLimit from 'express-rate-limit'; import fs from 'fs'; -import gravatarUrl from 'gravatar-url'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; import path from 'path'; @@ -72,19 +71,19 @@ settingsRoutes.get('/main', (req, res, next) => { res.status(200).json(filteredMainSettings(req.user, settings.main)); }); -settingsRoutes.post('/main', (req, res) => { +settingsRoutes.post('/main', async (req, res) => { const settings = getSettings(); settings.main = merge(settings.main, req.body); - settings.save(); + await settings.save(); return res.status(200).json(settings.main); }); -settingsRoutes.post('/main/regenerate', (req, res, next) => { +settingsRoutes.post('/main/regenerate', async (req, res, next) => { const settings = getSettings(); - const main = settings.regenerateApiKey(); + const main = await settings.regenerateApiKey(); if (!req.user) { return next({ status: 500, message: 'User missing from request.' }); @@ -121,7 +120,7 @@ settingsRoutes.post('/plex', async (req, res, next) => { settings.plex.machineId = result.MediaContainer.machineIdentifier; settings.plex.name = result.MediaContainer.friendlyName; - settings.save(); + await settings.save(); } catch (e) { logger.error('Something went wrong testing Plex connection', { label: 'API', @@ -234,7 +233,7 @@ settingsRoutes.get('/plex/library', async (req, res) => { ...library, enabled: enabledLibraries.includes(library.id), })); - settings.save(); + await settings.save(); return res.status(200).json(settings.plex.libraries); }); @@ -285,7 +284,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => { Object.assign(settings.jellyfin, req.body); settings.jellyfin.serverId = result.Id; settings.jellyfin.name = result.ServerName; - settings.save(); + await settings.save(); } catch (e) { if (e instanceof ApiError) { logger.error('Something went wrong testing Jellyfin connection', { @@ -373,7 +372,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => { ...library, enabled: enabledLibraries.includes(library.id), })); - settings.save(); + await settings.save(); return res.status(200).json(settings.jellyfin.libraries); }); @@ -397,9 +396,7 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => { const users = resp.users.map((user) => ({ username: user.Name, id: user.Id, - thumb: user.PrimaryImageTag - ? `/Users/${user.Id}/Images/Primary/?tag=${user.PrimaryImageTag}&quality=90` - : gravatarUrl(user.Name, { default: 'mm', size: 200 }), + thumb: `/avatarproxy/${user.Id}`, email: user.Name, })); @@ -439,7 +436,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => { throw new Error('Tautulli version not supported'); } - settings.save(); + await settings.save(); } catch (e) { logger.error('Something went wrong testing Tautulli connection', { label: 'API', @@ -700,7 +697,7 @@ settingsRoutes.post<{ jobId: JobId }>( settingsRoutes.post<{ jobId: JobId }>( '/jobs/:jobId/schedule', - (req, res, next) => { + async (req, res, next) => { const scheduledJob = scheduledJobs.find( (job) => job.id === req.params.jobId ); @@ -714,7 +711,7 @@ settingsRoutes.post<{ jobId: JobId }>( if (result) { settings.jobs[scheduledJob.id].schedule = req.body.schedule; - settings.save(); + await settings.save(); scheduledJob.cronSchedule = req.body.schedule; @@ -771,11 +768,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>( settingsRoutes.post( '/initialize', isAuthenticated(Permission.ADMIN), - (_req, res) => { + async (_req, res) => { const settings = getSettings(); settings.public.initialized = true; - settings.save(); + await settings.save(); return res.status(200).json(settings.public); } diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index be2fd89a8..5b2e1715b 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => { res.status(200).json(settings.notifications.agents.discord); }); -notificationRoutes.post('/discord', (req, res) => { +notificationRoutes.post('/discord', async (req, res) => { const settings = getSettings(); settings.notifications.agents.discord = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.discord); }); @@ -65,11 +65,11 @@ notificationRoutes.get('/slack', (_req, res) => { res.status(200).json(settings.notifications.agents.slack); }); -notificationRoutes.post('/slack', (req, res) => { +notificationRoutes.post('/slack', async (req, res) => { const settings = getSettings(); settings.notifications.agents.slack = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.slack); }); @@ -99,11 +99,11 @@ notificationRoutes.get('/telegram', (_req, res) => { res.status(200).json(settings.notifications.agents.telegram); }); -notificationRoutes.post('/telegram', (req, res) => { +notificationRoutes.post('/telegram', async (req, res) => { const settings = getSettings(); settings.notifications.agents.telegram = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.telegram); }); @@ -133,11 +133,11 @@ notificationRoutes.get('/pushbullet', (_req, res) => { res.status(200).json(settings.notifications.agents.pushbullet); }); -notificationRoutes.post('/pushbullet', (req, res) => { +notificationRoutes.post('/pushbullet', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushbullet = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.pushbullet); }); @@ -167,11 +167,11 @@ notificationRoutes.get('/pushover', (_req, res) => { res.status(200).json(settings.notifications.agents.pushover); }); -notificationRoutes.post('/pushover', (req, res) => { +notificationRoutes.post('/pushover', async (req, res) => { const settings = getSettings(); settings.notifications.agents.pushover = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.pushover); }); @@ -201,11 +201,11 @@ notificationRoutes.get('/email', (_req, res) => { res.status(200).json(settings.notifications.agents.email); }); -notificationRoutes.post('/email', (req, res) => { +notificationRoutes.post('/email', async (req, res) => { const settings = getSettings(); settings.notifications.agents.email = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.email); }); @@ -235,11 +235,11 @@ notificationRoutes.get('/webpush', (_req, res) => { res.status(200).json(settings.notifications.agents.webpush); }); -notificationRoutes.post('/webpush', (req, res) => { +notificationRoutes.post('/webpush', async (req, res) => { const settings = getSettings(); settings.notifications.agents.webpush = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.webpush); }); @@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => { res.status(200).json(response); }); -notificationRoutes.post('/webhook', (req, res, next) => { +notificationRoutes.post('/webhook', async (req, res, next) => { const settings = getSettings(); try { JSON.parse(req.body.options.jsonPayload); @@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', (req, res, next) => { authHeader: req.body.options.authHeader, }, }; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.webhook); } catch (e) { @@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => { res.status(200).json(settings.notifications.agents.lunasea); }); -notificationRoutes.post('/lunasea', (req, res) => { +notificationRoutes.post('/lunasea', async (req, res) => { const settings = getSettings(); settings.notifications.agents.lunasea = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.lunasea); }); @@ -385,11 +385,11 @@ notificationRoutes.get('/gotify', (_req, res) => { res.status(200).json(settings.notifications.agents.gotify); }); -notificationRoutes.post('/gotify', (req, res) => { +notificationRoutes.post('/gotify', async (req, res) => { const settings = getSettings(); settings.notifications.agents.gotify = req.body; - settings.save(); + await settings.save(); res.status(200).json(settings.notifications.agents.gotify); }); diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts index c2b0a6f52..efa586658 100644 --- a/server/routes/settings/radarr.ts +++ b/server/routes/settings/radarr.ts @@ -12,7 +12,7 @@ radarrRoutes.get('/', (_req, res) => { res.status(200).json(settings.radarr); }); -radarrRoutes.post('/', (req, res) => { +radarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newRadarr = req.body as RadarrSettings; @@ -31,7 +31,7 @@ radarrRoutes.post('/', (req, res) => { } settings.radarr = [...settings.radarr, newRadarr]; - settings.save(); + await settings.save(); return res.status(201).json(newRadarr); }); @@ -76,7 +76,7 @@ radarrRoutes.post< radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( '/:id', - (req, res, next) => { + async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( @@ -102,7 +102,7 @@ radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( ...req.body, id: Number(req.params.id), } as RadarrSettings; - settings.save(); + await settings.save(); return res.status(200).json(settings.radarr[radarrIndex]); } @@ -134,7 +134,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => { ); }); -radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => { +radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => { const settings = getSettings(); const radarrIndex = settings.radarr.findIndex( @@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => { } const removed = settings.radarr.splice(radarrIndex, 1); - settings.save(); + await settings.save(); return res.status(200).json(removed[0]); }); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 358d07002..84bf4d793 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -12,7 +12,7 @@ sonarrRoutes.get('/', (_req, res) => { res.status(200).json(settings.sonarr); }); -sonarrRoutes.post('/', (req, res) => { +sonarrRoutes.post('/', async (req, res) => { const settings = getSettings(); const newSonarr = req.body as SonarrSettings; @@ -31,7 +31,7 @@ sonarrRoutes.post('/', (req, res) => { } settings.sonarr = [...settings.sonarr, newSonarr]; - settings.save(); + await settings.save(); return res.status(201).json(newSonarr); }); @@ -43,13 +43,14 @@ sonarrRoutes.post('/test', async (req, res, next) => { url: SonarrAPI.buildUrl(req.body, '/api/v3'), }); - const urlBase = await sonarr - .getSystemStatus() - .then((value) => value.urlBase) - .catch(() => req.body.baseUrl); + const systemStatus = await sonarr.getSystemStatus(); + const sonarrMajorVersion = Number(systemStatus.version.split('.')[0]); + + const urlBase = systemStatus.urlBase; const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); - const languageProfiles = await sonarr.getLanguageProfiles(); + const languageProfiles = + sonarrMajorVersion <= 3 ? await sonarr.getLanguageProfiles() : null; const tags = await sonarr.getTags(); return res.status(200).json({ @@ -72,7 +73,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { } }); -sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { +sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( @@ -100,12 +101,12 @@ sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { ...req.body, id: Number(req.params.id), } as SonarrSettings; - settings.save(); + await settings.save(); return res.status(200).json(settings.sonarr[sonarrIndex]); }); -sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { +sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => { const settings = getSettings(); const sonarrIndex = settings.sonarr.findIndex( @@ -119,7 +120,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { } const removed = settings.sonarr.splice(sonarrIndex, 1); - settings.save(); + await settings.save(); return res.status(200).json(removed[0]); }); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 83ad0910b..2a29c0374 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -539,12 +539,7 @@ router.post( ).toString('base64'), email: jellyfinUser?.Name, permissions: settings.main.defaultPermissions, - avatar: jellyfinUser?.PrimaryImageTag - ? `/Users/${jellyfinUser.Id}/Images/Primary/?tag=${jellyfinUser.PrimaryImageTag}&quality=90` - : gravatarUrl(jellyfinUser?.Name ?? '', { - default: 'mm', - size: 200, - }), + avatar: `/avatarproxy/${jellyfinUser?.Id}`, userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN diff --git a/server/utils/appDataVolume.ts b/server/utils/appDataVolume.ts index 73c80b2c5..837f7f669 100644 --- a/server/utils/appDataVolume.ts +++ b/server/utils/appDataVolume.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'fs'; +import { accessSync, existsSync } from 'fs'; import path from 'path'; const CONFIG_PATH = process.env.CONFIG_DIRECTORY @@ -14,3 +14,12 @@ export const appDataStatus = (): boolean => { export const appDataPath = (): string => { return CONFIG_PATH; }; + +export const appDataPermissions = (): boolean => { + try { + accessSync(CONFIG_PATH); + return true; + } catch (err) { + return false; + } +}; diff --git a/server/utils/customProxyAgent.ts b/server/utils/customProxyAgent.ts new file mode 100644 index 000000000..3b6223685 --- /dev/null +++ b/server/utils/customProxyAgent.ts @@ -0,0 +1,111 @@ +import type { ProxySettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import type { Dispatcher } from 'undici'; +import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; + +export default async function createCustomProxyAgent( + proxySettings: ProxySettings +) { + const defaultAgent = new Agent(); + + const skipUrl = (url: string) => { + const hostname = new URL(url).hostname; + + if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) { + return true; + } + + for (const address of proxySettings.bypassFilter.split(',')) { + const trimmedAddress = address.trim(); + if (!trimmedAddress) { + continue; + } + + if (trimmedAddress.startsWith('*')) { + const domain = trimmedAddress.slice(1); + if (hostname.endsWith(domain)) { + return true; + } + } else if (hostname === trimmedAddress) { + return true; + } + } + + return false; + }; + + const noProxyInterceptor = ( + dispatch: Dispatcher['dispatch'] + ): Dispatcher['dispatch'] => { + return (opts, handler) => { + const url = opts.origin?.toString(); + return url && skipUrl(url) + ? defaultAgent.dispatch(opts, handler) + : dispatch(opts, handler); + }; + }; + + const token = + proxySettings.user && proxySettings.password + ? `Basic ${Buffer.from( + `${proxySettings.user}:${proxySettings.password}` + ).toString('base64')}` + : undefined; + + try { + const proxyAgent = new ProxyAgent({ + uri: + (proxySettings.useSsl ? 'https://' : 'http://') + + proxySettings.hostname + + ':' + + proxySettings.port, + token, + interceptors: { + Client: [noProxyInterceptor], + }, + }); + + setGlobalDispatcher(proxyAgent); + } catch (e) { + logger.error('Failed to connect to the proxy: ' + e.message, { + label: 'Proxy', + }); + setGlobalDispatcher(defaultAgent); + return; + } + + try { + const res = await fetch('https://www.google.com', { method: 'HEAD' }); + if (res.ok) { + logger.debug('HTTP(S) proxy connected successfully', { label: 'Proxy' }); + } else { + logger.error('Proxy responded, but with a non-OK status: ' + res.status, { + label: 'Proxy', + }); + setGlobalDispatcher(defaultAgent); + } + } catch (e) { + logger.error( + 'Failed to connect to the proxy: ' + e.message + ': ' + e.cause, + { label: 'Proxy' } + ); + setGlobalDispatcher(defaultAgent); + } +} + +function isLocalAddress(hostname: string) { + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return true; + } + + const privateIpRanges = [ + /^10\./, // 10.x.x.x + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.x.x - 172.31.x.x + /^192\.168\./, // 192.168.x.x + ]; + if (privateIpRanges.some((regex) => regex.test(hostname))) { + return true; + } + + return false; +} diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts index 387ec5ce4..18d03ea64 100644 --- a/server/utils/restartFlag.ts +++ b/server/utils/restartFlag.ts @@ -13,7 +13,8 @@ class RestartFlag { return ( this.settings.csrfProtection !== settings.csrfProtection || - this.settings.trustProxy !== settings.trustProxy + this.settings.trustProxy !== settings.trustProxy || + this.settings.proxy.enabled !== settings.proxy.enabled ); } } diff --git a/src/components/Common/CachedImage/index.tsx b/src/components/Common/CachedImage/index.tsx index b01a47bb4..a6d2fb001 100644 --- a/src/components/Common/CachedImage/index.tsx +++ b/src/components/Common/CachedImage/index.tsx @@ -25,11 +25,8 @@ const CachedImage = ({ src, type, ...props }: CachedImageProps) => { ? src.replace(/^https:\/\/image\.tmdb\.org\//, '/imageproxy/') : src; } else if (type === 'avatar') { - // jellyfin avatar (in any) - const jellyfinAvatar = src.match( - /(\/Users\/\w+\/Images\/Primary\/?\?tag=\w+&quality=90)$/ - )?.[1]; - imageUrl = jellyfinAvatar ? `/avatarproxy` + jellyfinAvatar : src; + // jellyfin avatar (if any) + imageUrl = src; } else { return null; } diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index f7aac0d96..2d1e0219f 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -55,6 +55,17 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', locale: 'Display Language', + proxyEnabled: 'HTTP(S) Proxy', + proxyHostname: 'Proxy Hostname', + proxyPort: 'Proxy Port', + proxySsl: 'Use SSL For Proxy', + proxyUser: 'Proxy Username', + proxyPassword: 'Proxy Password', + proxyBypassFilter: 'Proxy Ignored Addresses', + proxyBypassFilterTip: + "Use ',' as a separator, and '*.' as a wildcard for subdomains", + proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses', + validationProxyPort: 'You must provide a valid port', }); const SettingsMain = () => { @@ -82,6 +93,12 @@ const SettingsMain = () => { intl.formatMessage(messages.validationApplicationUrlTrailingSlash), (value) => !value || !value.endsWith('/') ), + proxyPort: Yup.number().when('proxyEnabled', { + is: (proxyEnabled: boolean) => proxyEnabled, + then: Yup.number().required( + intl.formatMessage(messages.validationProxyPort) + ), + }), }); const regenerate = async () => { @@ -137,6 +154,14 @@ const SettingsMain = () => { partialRequestsEnabled: data?.partialRequestsEnabled, trustProxy: data?.trustProxy, cacheImages: data?.cacheImages, + proxyEnabled: data?.proxy?.enabled, + proxyHostname: data?.proxy?.hostname, + proxyPort: data?.proxy?.port, + proxySsl: data?.proxy?.useSsl, + proxyUser: data?.proxy?.user, + proxyPassword: data?.proxy?.password, + proxyBypassFilter: data?.proxy?.bypassFilter, + proxyBypassLocalAddresses: data?.proxy?.bypassLocalAddresses, }} enableReinitialize validationSchema={MainSettingsSchema} @@ -158,6 +183,16 @@ const SettingsMain = () => { partialRequestsEnabled: values.partialRequestsEnabled, trustProxy: values.trustProxy, cacheImages: values.cacheImages, + proxy: { + enabled: values.proxyEnabled, + hostname: values.proxyHostname, + port: values.proxyPort, + useSsl: values.proxySsl, + user: values.proxyUser, + password: values.proxyPassword, + bypassFilter: values.proxyBypassFilter, + bypassLocalAddresses: values.proxyBypassLocalAddresses, + }, }), }); if (!res.ok) throw new Error(); @@ -437,6 +472,176 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue('proxyEnabled', !values.proxyEnabled); + }} + /> +
+
+ {values.proxyEnabled && ( + <> +
+ +
+
+ +
+ {errors.proxyHostname && + touched.proxyHostname && + typeof errors.proxyHostname === 'string' && ( +
{errors.proxyHostname}
+ )} +
+
+
+ +
+
+ +
+ {errors.proxyPort && + touched.proxyPort && + typeof errors.proxyPort === 'string' && ( +
{errors.proxyPort}
+ )} +
+
+
+ +
+ { + setFieldValue('proxySsl', !values.proxySsl); + }} + /> +
+
+
+ +
+
+ +
+ {errors.proxyUser && + touched.proxyUser && + typeof errors.proxyUser === 'string' && ( +
{errors.proxyUser}
+ )} +
+
+
+ +
+
+ +
+ {errors.proxyPassword && + touched.proxyPassword && + typeof errors.proxyPassword === 'string' && ( +
{errors.proxyPassword}
+ )} +
+
+
+ +
+
+ +
+ {errors.proxyBypassFilter && + touched.proxyBypassFilter && + typeof errors.proxyBypassFilter === 'string' && ( +
+ {errors.proxyBypassFilter} +
+ )} +
+
+
+ +
+ { + setFieldValue( + 'proxyBypassLocalAddresses', + !values.proxyBypassLocalAddresses + ); + }} + /> +
+
+ + )}
diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index ed6d1f564..f2bcd4dbc 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -86,10 +86,12 @@ interface TestResponse { id: number; path: string; }[]; - languageProfiles: { - id: number; - name: string; - }[]; + languageProfiles: + | { + id: number; + name: string; + }[] + | null; tags: { id: number; label: string; @@ -112,7 +114,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { const [testResponse, setTestResponse] = useState({ profiles: [], rootFolders: [], - languageProfiles: [], + languageProfiles: null, tags: [], }); const SonarrSettingsSchema = Yup.object().shape({ @@ -137,9 +139,11 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { activeProfileId: Yup.string().required( intl.formatMessage(messages.validationProfileRequired) ), - activeLanguageProfileId: Yup.number().required( - intl.formatMessage(messages.validationLanguageProfileRequired) - ), + activeLanguageProfileId: testResponse.languageProfiles + ? Yup.number().required( + intl.formatMessage(messages.validationLanguageProfileRequired) + ) + : Yup.number(), externalUrl: Yup.string() .url(intl.formatMessage(messages.validationApplicationUrl)) .test( @@ -658,54 +662,56 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { )}
-
- -
-
- - - {testResponse.languageProfiles.length > 0 && - testResponse.languageProfiles.map((language) => ( - - ))} - + {testResponse.languageProfiles && ( +
+ +
+
+ + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
+ {errors.activeLanguageProfileId && + touched.activeLanguageProfileId && ( +
+ {errors.activeLanguageProfileId} +
+ )}
- {errors.activeLanguageProfileId && - touched.activeLanguageProfileId && ( -
- {errors.activeLanguageProfileId} -
- )}
-
+ )}
-
- -
-
- - - {testResponse.languageProfiles.length > 0 && - testResponse.languageProfiles.map((language) => ( - - ))} - + {testResponse.languageProfiles && ( +
+ +
+
+ + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
+ {errors.activeAnimeLanguageProfileId && + touched.activeAnimeLanguageProfileId && ( +
+ {errors.activeAnimeLanguageProfileId} +
+ )}
- {errors.activeAnimeLanguageProfileId && - touched.activeAnimeLanguageProfileId && ( -
- {errors.activeAnimeLanguageProfileId} -
- )}
-
+ )}