diff --git a/.dockerignore b/.dockerignore index 21a5da869..5a009f2af 100644 --- a/.dockerignore +++ b/.dockerignore @@ -18,7 +18,7 @@ config/logs/* config/*.json dist Dockerfile* -docker-compose.yml +compose.yaml docs LICENSE node_modules diff --git a/.gitattributes b/.gitattributes index eb5d2314f..d9863caf6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -40,7 +40,7 @@ docs export-ignore .all-contributorsrc export-ignore .editorconfig export-ignore Dockerfile.local export-ignore -docker-compose.yml export-ignore +compose.yaml export-ignore stylelint.config.js export-ignore public/os_logo_filled.png export-ignore 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/CONTRIBUTING.md b/CONTRIBUTING.md index c5f768c29..e76b2c14e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to 4. Run the development environment: ```bash - pnpm + pnpm install pnpm dev ``` - - Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. + - Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. 5. Create your patch and test your changes. diff --git a/docker-compose.yml b/compose.yaml similarity index 93% rename from docker-compose.yml rename to compose.yaml index 91b76e1e4..94705357f 100644 --- a/docker-compose.yml +++ b/compose.yaml @@ -1,4 +1,3 @@ -version: '3' services: jellyseerr: build: diff --git a/docs/extending-jellyseerr/reverse-proxy.mdx b/docs/extending-jellyseerr/reverse-proxy.mdx index 1ac365454..c78ae9154 100644 --- a/docs/extending-jellyseerr/reverse-proxy.mdx +++ b/docs/extending-jellyseerr/reverse-proxy.mdx @@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain. ## Traefik (v2) -Add the following labels to the Jellyseerr service in your `docker-compose.yml` file: +Add the following labels to the Jellyseerr service in your `compose.yaml` file: ```yaml labels: 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/docs/getting-started/buildfromsource.mdx b/docs/getting-started/buildfromsource.mdx index 5b39912cd..c22ff23af 100644 --- a/docs/getting-started/buildfromsource.mdx +++ b/docs/getting-started/buildfromsource.mdx @@ -12,49 +12,12 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; ### Prerequisites - - - - [Node.js 18.x](https://nodejs.org/en/download/) - - [Yarn 1.x](https://classic.yarnpkg.com/lang/en/docs/install) - - [Git](https://git-scm.com/downloads) - - - - [Node.js 20.x](https://nodejs.org/en/download/) - [Pnpm 9.x](https://pnpm.io/installation) - [Git](https://git-scm.com/downloads) - - - ## Unix (Linux, macOS) ### Installation - - -1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it: -```bash -sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr -``` -2. Clone the Jellyseerr repository and checkout the latest release: -```bash -git clone https://github.com/Fallenbagel/jellyseerr.git -cd jellyseerr -git checkout main -``` -3. Install the dependencies: -```bash -CYPRESS_INSTALL_BINARY=0 yarn install --frozen-lockfile --network-timeout 1000000 -``` -4. Build the project: -```bash -yarn build -``` -5. Start Jellyseerr: -```bash -yarn start -``` - - 1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it: ```bash sudo mkdir -p /opt/jellyseerr && cd /opt/jellyseerr @@ -77,8 +40,6 @@ pnpm build ```bash pnpm start ``` - - :::info You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser. @@ -234,33 +195,6 @@ pm2 status jellyseerr ## Windows ### Installation - - -1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it: -```powershell -mkdir C:\jellyseerr -cd C:\jellyseerr -``` -2. Clone the Jellyseerr repository and checkout the latest release: -```powershell -git clone https://github.com/Fallenbagel/jellyseerr.git . -git checkout main -``` -3. Install the dependencies: -```powershell -npm install -g win-node-env -set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000 -``` -4. Build the project: -```powershell -yarn build -``` -5. Start Jellyseerr: -```powershell -yarn start -``` - - 1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it: ```powershell mkdir C:\jellyseerr @@ -284,8 +218,6 @@ pnpm build ```powershell pnpm start ``` - - :::tip You can add the environment variables to a `.env` file in the Jellyseerr directory. @@ -313,6 +245,7 @@ node dist/index.js - Set the trigger to "When the computer starts" - Set the action to "Start a program" - Set the program/script to the path of the `start-jellyseerr.bat` file +- Set the "Start in" to the jellyseerr directory. - Click "Finish" Now, Jellyseerr will start when the computer boots up in the background. diff --git a/docs/getting-started/docker.mdx b/docs/getting-started/docker.mdx index 3d8d690ce..5e710735c 100644 --- a/docs/getting-started/docker.mdx +++ b/docs/getting-started/docker.mdx @@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/). #### Installation: -Define the `jellyseerr` service in your `docker-compose.yml` as follows: +Define the `jellyseerr` service in your `compose.yaml` as follows: ```yaml --- services: @@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable Then, start all services defined in the Compose file: ```bash -docker-compose up -d +docker compose up -d ``` #### Updating: Pull the latest image: ```bash -docker-compose pull jellyseerr +docker compose pull jellyseerr ``` Then, restart all services defined in the Compose file: ```bash -docker-compose up -d +docker compose up -d ``` :::tip You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files. diff --git a/overseerr-api.yml b/overseerr-api.yml index 96a4520a7..9e2505f48 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1988,6 +1988,9 @@ paths: appDataPath: type: string example: /app/config + appDataPermissions: + type: boolean + example: true /settings/main: get: summary: Get main settings @@ -4139,6 +4142,21 @@ paths: '412': description: Item has already been blacklisted /blacklist/{tmdbId}: + get: + summary: Get media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Blacklist details in JSON delete: summary: Remove media from blacklist tags: diff --git a/package.json b/package.json index d032c4aa3..6d4a7c2c2 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 0dfddefc9..0dc1f967d 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -32,13 +32,27 @@ 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..d0c4d7c74 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -138,39 +138,38 @@ class JellyfinAPI extends ExternalAPI { try { return await authenticate(true); } catch (e) { - logger.debug(`Failed to authenticate with headers: ${e.message}`, { + logger.debug('Failed to authenticate with headers', { label: 'Jellyfin API', + error: e.cause.message ?? e.cause.statusText, ip: ClientIP, }); + + if (!e.cause.status) { + throw new ApiError(404, ApiErrorCode.InvalidUrl); + } + + if (e.cause.status === 401) { + throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials); + } } try { return await authenticate(false); } catch (e) { - const status = e.cause?.status; - - const networkErrorCodes = new Set([ - 'ECONNREFUSED', - 'EHOSTUNREACH', - 'ENOTFOUND', - 'ETIMEDOUT', - 'ECONNRESET', - 'EADDRINUSE', - 'ENETDOWN', - 'ENETUNREACH', - 'EPIPE', - 'ECONNABORTED', - 'EPROTO', - 'EHOSTDOWN', - 'EAI_AGAIN', - 'ERR_INVALID_URL', - ]); - - if (networkErrorCodes.has(e.code) || status === 404) { - throw new ApiError(status, ApiErrorCode.InvalidUrl); + if (e.cause.status === 401) { + throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials); } - throw new ApiError(status, ApiErrorCode.InvalidCredentials); + logger.error( + 'Something went wrong while authenticating with the Jellyfin server', + { + label: 'Jellyfin API', + error: e.cause.message ?? e.cause.statusText, + ip: ClientIP, + } + ); + + throw new ApiError(e.cause.status, ApiErrorCode.Unknown); } } @@ -198,8 +197,8 @@ class JellyfinAPI extends ExternalAPI { return serverResponse.ServerName; } catch (e) { logger.error( - `Something went wrong while getting the server name from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting the server name from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.Unknown); @@ -213,8 +212,8 @@ class JellyfinAPI extends ExternalAPI { return { users: userReponse }; } catch (e) { logger.error( - `Something went wrong while getting the account from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting the account from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); @@ -229,8 +228,8 @@ class JellyfinAPI extends ExternalAPI { return userReponse; } catch (e) { logger.error( - `Something went wrong while getting the account from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting the account from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); @@ -253,8 +252,11 @@ class JellyfinAPI extends ExternalAPI { return this.mapLibraries(mediaFolderResponse.Items); } catch (e) { logger.error( - `Something went wrong while getting libraries from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting libraries from the Jellyfin server', + { + label: 'Jellyfin API', + error: e.cause.message ?? e.cause.statusText, + } ); return []; @@ -308,8 +310,8 @@ class JellyfinAPI extends ExternalAPI { ); } catch (e) { logger.error( - `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting library content from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); @@ -329,8 +331,8 @@ class JellyfinAPI extends ExternalAPI { return itemResponse; } catch (e) { logger.error( - `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting library content from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); @@ -354,8 +356,8 @@ class JellyfinAPI extends ExternalAPI { } logger.error( - `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting library content from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } @@ -368,8 +370,8 @@ class JellyfinAPI extends ExternalAPI { return seasonResponse.Items; } catch (e) { logger.error( - `Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting the list of seasons from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); @@ -393,8 +395,8 @@ class JellyfinAPI extends ExternalAPI { ); } catch (e) { logger.error( - `Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while getting the list of episodes from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); @@ -410,8 +412,8 @@ class JellyfinAPI extends ExternalAPI { ).AccessToken; } catch (e) { logger.error( - `Something went wrong while creating an API key the Jellyfin server: ${e.message}`, - { label: 'Jellyfin API' } + 'Something went wrong while creating an API key from the Jellyfin server', + { label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText } ); throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); 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/entity/Blacklist.ts b/server/entity/Blacklist.ts index 5e24419dc..4ce3a86e2 100644 --- a/server/entity/Blacklist.ts +++ b/server/entity/Blacklist.ts @@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem { status: MediaStatus.BLACKLISTED, status4k: MediaStatus.BLACKLISTED, mediaType: blacklistRequest.mediaType, - blacklist: blacklist, + blacklist: Promise.resolve(blacklist), }); await mediaRepository.save(media); } else { - media.blacklist = blacklist; + media.blacklist = Promise.resolve(blacklist); media.status = MediaStatus.BLACKLISTED; media.status4k = MediaStatus.BLACKLISTED; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index de10cebcb..a9991dc4c 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -118,10 +118,8 @@ class Media { @OneToMany(() => Issue, (issue) => issue.media, { cascade: true }) public issues: Issue[]; - @OneToOne(() => Blacklist, (blacklist) => blacklist.media, { - eager: true, - }) - public blacklist: Blacklist; + @OneToOne(() => Blacklist, (blacklist) => blacklist.media) + public blacklist: Promise; @CreateDateColumn() public createdAt: Date; 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 f074872bb..f6049630c 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 074a4fcdc..29447f534 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -2,7 +2,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'; @@ -99,6 +99,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; @@ -119,6 +130,7 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; locale: string; + proxy: ProxySettings; } interface PublicSettings { @@ -325,6 +337,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: '', @@ -479,10 +501,6 @@ class Settings { } get main(): MainSettings { - if (!this.data.main.apiKey) { - this.data.main.apiKey = this.generateApiKey(); - this.save(); - } return this.data.main; } @@ -584,29 +602,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; } @@ -618,15 +627,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 * @@ -641,30 +641,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); - - this.data = merge(this.data, parsedJson); + const migratedData = await runMigrations(parsedJson, SETTINGS_PATH); + this.data = merge(this.data, migratedData); + } - if (process.env.API_KEY) { - if (this.main.apiKey != process.env.API_KEY) { - this.main.apiKey = process.env.API_KEY; - } + // 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; } - - this.save(); } + 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 856016e11..801140000 100644 --- a/server/lib/settings/migrator.ts +++ b/server/lib/settings/migrator.ts @@ -1,30 +1,100 @@ 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'); export const runMigrations = async ( - settings: AllSettings + 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); + + if (settingsBefore !== settingsAfter) { + // a migration occured + // we check that the new config will be saved + 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', + } + ); + logger.error( + 'A common cause for this issue is a permission error of your configuration folder.', + { + label: 'Settings Migrator', + } ); + process.exit(); } return migrated; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 560f04d57..19e99a8ed 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); @@ -301,64 +299,84 @@ authRoutes.post('/jellyfin', async (req, res, next) => { where: { jellyfinUserId: account.User.Id }, }); - if (!user && !(await userRepository.count())) { + const missingAdminUser = !user && !(await userRepository.count()); + if ( + missingAdminUser || + settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED + ) { // Check if user is admin on jellyfin if (account.User.Policy.IsAdministrator === false) { throw new ApiError(403, ApiErrorCode.NotAdmin); } - logger.info( - 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', - { - label: 'API', - ip: req.ip, - jellyfinUsername: account.User.Name, - } - ); + if ( + body.serverType !== MediaServerType.JELLYFIN && + body.serverType !== MediaServerType.EMBY + ) { + throw new Error('select_server_type'); + } + settings.main.mediaServerType = body.serverType; - // User doesn't exist, and there are no users in the database, we'll create the user - // with admin permissions - switch (body.serverType) { - case MediaServerType.EMBY: - settings.main.mediaServerType = MediaServerType.EMBY; - user = new User({ - email: body.email || account.User.Name, + if (missingAdminUser) { + logger.info( + 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr', + { + label: 'API', + ip: req.ip, jellyfinUsername: account.User.Name, - jellyfinUserId: account.User.Id, - 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, - }), - userType: UserType.EMBY, - }); + } + ); - break; - case MediaServerType.JELLYFIN: - settings.main.mediaServerType = MediaServerType.JELLYFIN; - user = new User({ - email: body.email || account.User.Name, + // User doesn't exist, and there are no users in the database, we'll create the user + // with admin permissions + + user = new User({ + id: 1, + email: body.email || account.User.Name, + jellyfinUsername: account.User.Name, + jellyfinUserId: account.User.Id, + jellyfinDeviceId: deviceId, + jellyfinAuthToken: account.AccessToken, + permissions: Permission.ADMIN, + avatar: `/avatarproxy/${account.User.Id}`, + userType: + body.serverType === MediaServerType.JELLYFIN + ? UserType.JELLYFIN + : UserType.EMBY, + }); + + await userRepository.save(user); + } else { + logger.info( + 'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr', + { + label: 'API', + ip: req.ip, jellyfinUsername: account.User.Name, - jellyfinUserId: account.User.Id, - 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, - }), - userType: UserType.JELLYFIN, - }); + } + ); + + // User alread exist but settings.json is not configured, we'll edit the admin user + + user = await userRepository.findOne({ + where: { id: 1 }, + }); + if (!user) { + throw new Error('Unable to find admin user to edit'); + } + user.email = body.email || account.User.Name; + user.jellyfinUsername = account.User.Name; + user.jellyfinUserId = account.User.Id; + user.jellyfinDeviceId = deviceId; + user.jellyfinAuthToken = account.AccessToken; + user.permissions = Permission.ADMIN; + user.avatar = `/avatarproxy/${account.User.Id}`; + user.userType = + body.serverType === MediaServerType.JELLYFIN + ? UserType.JELLYFIN + : UserType.EMBY; - break; - default: - throw new Error('select_server_type'); + await userRepository.save(user); } // Create an API key on Jellyfin from this admin user @@ -378,10 +396,8 @@ 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); } // User already exists, let's update their information else if (account.User.Id === user?.jellyfinUserId) { @@ -401,27 +417,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 +455,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/blacklist.ts b/server/routes/blacklist.ts index 4a07a4998..bb2dafe88 100644 --- a/server/routes/blacklist.ts +++ b/server/routes/blacklist.ts @@ -2,14 +2,12 @@ import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { Blacklist } from '@server/entity/Blacklist'; import Media from '@server/entity/Media'; -import { NotFoundError } from '@server/entity/Watchlist'; import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces'; import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import rateLimit from 'express-rate-limit'; -import { QueryFailedError } from 'typeorm'; +import { EntityNotFoundError, QueryFailedError } from 'typeorm'; import { z } from 'zod'; const blacklistRoutes = Router(); @@ -26,7 +24,6 @@ blacklistRoutes.get( isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { type: 'or', }), - rateLimit({ windowMs: 60 * 1000, max: 50 }), async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 25; const skip = req.query.skip ? Number(req.query.skip) : 0; @@ -71,6 +68,32 @@ blacklistRoutes.get( } ); +blacklistRoutes.get( + '/:id', + isAuthenticated([Permission.MANAGE_BLACKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const blacklisteRepository = getRepository(Blacklist); + + const blacklistItem = await blacklisteRepository.findOneOrFail({ + where: { tmdbId: Number(req.params.id) }, + }); + + return res.status(200).send(blacklistItem); + } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ + status: 401, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } + } +); + blacklistRoutes.post( '/', isAuthenticated([Permission.MANAGE_BLACKLIST], { @@ -134,7 +157,7 @@ blacklistRoutes.delete( return res.status(204).send(); } catch (e) { - if (e instanceof NotFoundError) { + if (e instanceof EntityNotFoundError) { return next({ status: 401, message: e.message, diff --git a/server/routes/index.ts b/server/routes/index.ts index c7c8389e0..120e2e86b 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 083e1eb57..8f6c92b0d 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 3d6b6b0d3..bc8c5ef7c 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'; @@ -70,19 +69,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.' }); @@ -119,7 +118,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', @@ -232,7 +231,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); }); @@ -283,7 +282,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', { @@ -371,7 +370,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); }); @@ -395,9 +394,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, })); @@ -437,7 +434,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', @@ -698,7 +695,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 ); @@ -712,7 +709,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; @@ -769,11 +766,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/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx index 0908d3735..8d619aa3c 100644 --- a/src/components/BlacklistBlock/index.tsx +++ b/src/components/BlacklistBlock/index.tsx @@ -1,5 +1,6 @@ import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import Tooltip from '@app/components/Common/Tooltip'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -10,6 +11,7 @@ import Link from 'next/link'; import { useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; const messages = defineMessages('component.BlacklistBlock', { blacklistedby: 'Blacklisted By', @@ -17,13 +19,13 @@ const messages = defineMessages('component.BlacklistBlock', { }); interface BlacklistBlockProps { - blacklistItem: Blacklist; + tmdbId: number; onUpdate?: () => void; onDelete?: () => void; } const BlacklistBlock = ({ - blacklistItem, + tmdbId, onUpdate, onDelete, }: BlacklistBlockProps) => { @@ -31,6 +33,7 @@ const BlacklistBlock = ({ const intl = useIntl(); const [isUpdating, setIsUpdating] = useState(false); const { addToast } = useToasts(); + const { data } = useSWR(`/api/v1/blacklist/${tmdbId}`); const removeFromBlacklist = async (tmdbId: number, title?: string) => { setIsUpdating(true); @@ -62,6 +65,14 @@ const BlacklistBlock = ({ setIsUpdating(false); }; + if (!data) { + return ( + <> + + + ); + } + return (
@@ -73,13 +84,13 @@ const BlacklistBlock = ({ - {blacklistItem.user.displayName} + {data.user.displayName} @@ -91,9 +102,7 @@ const BlacklistBlock = ({ >
+
+ +
+ { + 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} -
- )}
-
+ )}