Compare commits

...

6 Commits

Author SHA1 Message Date
Gauthier
acc3599f1b perf: try to improve performance by removing rate limit 2024-11-06 17:00:30 +01:00
Gauthier
64f4610b9f fix: resolve error when setup on second attempt (#1061) 2024-11-06 15:21:19 +08:00
Ludovic Ortega
2d3b777daf docs: migrate to docker compose v2 (#1073)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2024-11-04 22:48:37 +08:00
Fallenbagel
cf59102ef9 fix(externalapi): extract basic auth and pass it through header (#1062)
This commit adds extraction of basic authentication credentials from the URL and then pass the
credentials as the `Authorization` header. And then credentials are removed from the URL before
being passed to fetch. This is done because fetch request cannot be constructed using a URL with
credentials

fix #1027
2024-11-03 14:35:20 +08:00
Gauthier
ca838a00fa feat: add bypass list, bypass local addresses and username/password to proxy setting (#1059)
* fix: use fs/promises for settings

This PR switches from synchronous operations with the 'fs' module to asynchronous operations with
the 'fs/promises' module. It also corrects a small error with hostname migration.

* fix: add missing merge function of default and current config

* feat: add bypass list, bypass local addresses and username/password to proxy setting

This PR adds more options to the proxy setting, like username/password authentication, bypass list
of domains and bypass local addresses. The UX is taken from *arrs.

* fix: add error handling for proxy creating

* fix: remove logs
2024-10-31 16:10:45 +01:00
Gauthier
f2ed101e52 fix: use fs/promises for settings (#1057)
* fix: use fs/promises for settings

This PR switches from synchronous operations with the 'fs' module to asynchronous operations with
the 'fs/promises' module. It also corrects a small error with hostname migration.

* fix: add missing merge function of default and current config

* refactor: add more logs to migration
2024-10-31 15:51:57 +01:00
25 changed files with 574 additions and 217 deletions

View File

@@ -18,7 +18,7 @@ config/logs/*
config/*.json config/*.json
dist dist
Dockerfile* Dockerfile*
docker-compose.yml compose.yaml
docs docs
LICENSE LICENSE
node_modules node_modules

2
.gitattributes vendored
View File

@@ -40,7 +40,7 @@ docs export-ignore
.all-contributorsrc export-ignore .all-contributorsrc export-ignore
.editorconfig export-ignore .editorconfig export-ignore
Dockerfile.local export-ignore Dockerfile.local export-ignore
docker-compose.yml export-ignore compose.yaml export-ignore
stylelint.config.js export-ignore stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore public/os_logo_filled.png export-ignore

View File

@@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
pnpm dev 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. 5. Create your patch and test your changes.

View File

@@ -1,4 +1,3 @@
version: '3'
services: services:
jellyseerr: jellyseerr:
build: build:

View File

@@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## Traefik (v2) ## 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 ```yaml
labels: labels:

View File

@@ -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/). For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
#### Installation: #### Installation:
Define the `jellyseerr` service in your `docker-compose.yml` as follows: Define the `jellyseerr` service in your `compose.yaml` as follows:
```yaml ```yaml
--- ---
services: 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: Then, start all services defined in the Compose file:
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
#### Updating: #### Updating:
Pull the latest image: Pull the latest image:
```bash ```bash
docker-compose pull jellyseerr docker compose pull jellyseerr
``` ```
Then, restart all services defined in the Compose file: Then, restart all services defined in the Compose file:
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
:::tip :::tip
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files. You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.

2
next-env.d.ts vendored
View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information. // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

View File

@@ -1,5 +1,5 @@
import type { RateLimitOptions } from '@server/utils/rateLimit'; import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit'; // import rateLimit from '@server/utils/rateLimit';
import type NodeCache from 'node-cache'; import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds) // 5 minute default TTL (in seconds)
@@ -26,19 +26,34 @@ class ExternalAPI {
params: Record<string, string> = {}, params: Record<string, string> = {},
options: ExternalAPIOptions = {} options: ExternalAPIOptions = {}
) { ) {
if (options.rateLimit) { // if (options.rateLimit) {
this.fetch = rateLimit(fetch, options.rateLimit); // this.fetch = rateLimit(fetch, options.rateLimit);
} else { // } else {
this.fetch = fetch; // this.fetch = fetch;
// }
this.fetch = fetch;
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.baseUrl = baseUrl;
this.params = params; this.params = params;
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
this.cache = options.nodeCache; this.cache = options.nodeCache;
} }

View File

@@ -410,7 +410,7 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken; ).AccessToken;
} catch (e) { } catch (e) {
logger.error( 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' } { label: 'Jellyfin API' }
); );

View File

@@ -180,7 +180,7 @@ class PlexAPI {
settings.plex.libraries = []; settings.plex.libraries = [];
} }
settings.save(); await settings.save();
} }
public async getLibraryContents( public async getLibraryContents(

View File

@@ -23,6 +23,7 @@ import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy'; import imageproxy from '@server/routes/imageproxy';
import { appDataPermissions } from '@server/utils/appDataVolume'; import { appDataPermissions } from '@server/utils/appDataVolume';
import { getAppVersion } from '@server/utils/appVersion'; import { getAppVersion } from '@server/utils/appVersion';
import createCustomProxyAgent from '@server/utils/customProxyAgent';
import restartFlag from '@server/utils/restartFlag'; import restartFlag from '@server/utils/restartFlag';
import { getClientIp } from '@supercharge/request-ip'; import { getClientIp } from '@supercharge/request-ip';
import { TypeormStore } from 'connect-typeorm/out'; import { TypeormStore } from 'connect-typeorm/out';
@@ -38,7 +39,6 @@ import dns from 'node:dns';
import net from 'node:net'; import net from 'node:net';
import path from 'path'; import path from 'path';
import swaggerUi from 'swagger-ui-express'; import swaggerUi from 'swagger-ui-express';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import YAML from 'yamljs'; import YAML from 'yamljs';
if (process.env.forceIpv4First === 'true') { if (process.env.forceIpv4First === 'true') {
@@ -76,8 +76,8 @@ app
restartFlag.initializeSettings(settings.main); restartFlag.initializeSettings(settings.main);
// Register HTTP proxy // Register HTTP proxy
if (settings.main.httpProxy) { if (settings.main.proxy.enabled) {
setGlobalDispatcher(new ProxyAgent(settings.main.httpProxy)); await createCustomProxyAgent(settings.main.proxy);
} }
// Migrate library types // Migrate library types

View File

@@ -1,6 +1,6 @@
import logger from '@server/logger'; import logger from '@server/logger';
import type { RateLimitOptions } from '@server/utils/rateLimit'; import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit'; // import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { promises } from 'fs'; import { promises } from 'fs';
import mime from 'mime/lite'; import mime from 'mime/lite';
@@ -150,13 +150,14 @@ class ImageProxy {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.key = key; this.key = key;
if (options.rateLimitOptions) { // if (options.rateLimitOptions) {
this.fetch = rateLimit(fetch, { // this.fetch = rateLimit(fetch, {
...options.rateLimitOptions, // ...options.rateLimitOptions,
}); // });
} else { // } else {
this.fetch = fetch; // this.fetch = fetch;
} // }
this.fetch = fetch;
this.headers = options.headers || null; this.headers = options.headers || null;
} }

View File

@@ -129,7 +129,7 @@ class PlexScanner
}); });
settings.plex.libraries = newLibraries; settings.plex.libraries = newLibraries;
settings.save(); await settings.save();
} }
} else { } else {
for (const library of this.libraries) { for (const library of this.libraries) {

View File

@@ -2,7 +2,7 @@ import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions'; import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator'; import { runMigrations } from '@server/lib/settings/migrator';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import fs from 'fs'; import fs from 'fs/promises';
import { merge } from 'lodash'; import { merge } from 'lodash';
import path from 'path'; import path from 'path';
import webpush from 'web-push'; import webpush from 'web-push';
@@ -99,6 +99,17 @@ interface Quota {
quotaDays?: number; quotaDays?: number;
} }
export interface ProxySettings {
enabled: boolean;
hostname: string;
port: number;
useSsl: boolean;
user: string;
password: string;
bypassFilter: string;
bypassLocalAddresses: boolean;
}
export interface MainSettings { export interface MainSettings {
apiKey: string; apiKey: string;
applicationTitle: string; applicationTitle: string;
@@ -119,7 +130,7 @@ export interface MainSettings {
mediaServerType: number; mediaServerType: number;
partialRequestsEnabled: boolean; partialRequestsEnabled: boolean;
locale: string; locale: string;
httpProxy: string; proxy: ProxySettings;
} }
interface PublicSettings { interface PublicSettings {
@@ -326,7 +337,16 @@ class Settings {
mediaServerType: MediaServerType.NOT_CONFIGURED, mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true, partialRequestsEnabled: true,
locale: 'en', locale: 'en',
httpProxy: '', proxy: {
enabled: false,
hostname: '',
port: 8080,
useSsl: false,
user: '',
password: '',
bypassFilter: '',
bypassLocalAddresses: true,
},
}, },
plex: { plex: {
name: '', name: '',
@@ -481,10 +501,6 @@ class Settings {
} }
get main(): MainSettings { get main(): MainSettings {
if (!this.data.main.apiKey) {
this.data.main.apiKey = this.generateApiKey();
this.save();
}
return this.data.main; return this.data.main;
} }
@@ -586,29 +602,20 @@ class Settings {
} }
get clientId(): string { get clientId(): string {
if (!this.data.clientId) {
this.data.clientId = randomUUID();
this.save();
}
return this.data.clientId; return this.data.clientId;
} }
get vapidPublic(): string { get vapidPublic(): string {
this.generateVapidKeys();
return this.data.vapidPublic; return this.data.vapidPublic;
} }
get vapidPrivate(): string { get vapidPrivate(): string {
this.generateVapidKeys();
return this.data.vapidPrivate; return this.data.vapidPrivate;
} }
public regenerateApiKey(): MainSettings { public async regenerateApiKey(): Promise<MainSettings> {
this.main.apiKey = this.generateApiKey(); this.main.apiKey = this.generateApiKey();
this.save(); await this.save();
return this.main; return this.main;
} }
@@ -620,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 * Settings Load
* *
@@ -643,30 +641,51 @@ class Settings {
return this; return this;
} }
if (!fs.existsSync(SETTINGS_PATH)) { let data;
this.save(); try {
data = await fs.readFile(SETTINGS_PATH, 'utf-8');
} catch {
await this.save();
} }
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) { if (data) {
const parsedJson = JSON.parse(data); const parsedJson = JSON.parse(data);
this.data = await runMigrations(parsedJson, SETTINGS_PATH); const migratedData = await runMigrations(parsedJson, SETTINGS_PATH);
this.data = merge(this.data, migratedData);
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();
} }
// 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; return this;
} }
public save(): void { public async save(): Promise<void> {
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' ')); await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(this.data, undefined, ' ')
);
} }
} }

View File

@@ -1,15 +1,14 @@
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
const migrateHostname = (settings: any): AllSettings => { const migrateHostname = (settings: any): AllSettings => {
const oldJellyfinSettings = settings.jellyfin; if (settings.jellyfin?.hostname) {
if (oldJellyfinSettings && oldJellyfinSettings.hostname) { const { hostname } = settings.jellyfin;
const { hostname } = oldJellyfinSettings;
const protocolMatch = hostname.match(/^(https?):\/\//i); const protocolMatch = hostname.match(/^(https?):\/\//i);
const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https'; const useSsl = protocolMatch && protocolMatch[1].toLowerCase() === 'https';
const remainingUrl = hostname.replace(/^(https?):\/\//i, ''); const remainingUrl = hostname.replace(/^(https?):\/\//i, '');
const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/); const urlMatch = remainingUrl.match(/^([^:]+)(:([0-9]+))?(\/.*)?$/);
delete oldJellyfinSettings.hostname; delete settings.jellyfin.hostname;
if (urlMatch) { if (urlMatch) {
const [, ip, , port, urlBase] = urlMatch; const [, ip, , port, urlBase] = urlMatch;
settings.jellyfin = { settings.jellyfin = {
@@ -21,9 +20,7 @@ const migrateHostname = (settings: any): AllSettings => {
}; };
} }
} }
if (settings.jellyfin && settings.jellyfin.hostname) {
delete settings.jellyfin.hostname;
}
return settings; return settings;
}; };

View File

@@ -27,8 +27,14 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); try {
settings.jellyfin.apiKey = apiKey; 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; return settings;
}; };

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-console */
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import fs from 'fs/promises'; import fs from 'fs/promises';
@@ -15,9 +14,9 @@ export const runMigrations = async (
try { try {
// we read old backup and create a backup of currents settings // we read old backup and create a backup of currents settings
const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json'); const BACKUP_PATH = SETTINGS_PATH.replace('.json', '.old.json');
let oldBackup: Buffer | null = null; let oldBackup: string | null = null;
try { try {
oldBackup = await fs.readFile(BACKUP_PATH); oldBackup = await fs.readFile(BACKUP_PATH, 'utf-8');
} catch { } catch {
/* empty */ /* empty */
} }
@@ -37,7 +36,7 @@ export const runMigrations = async (
const { default: migrationFn } = await import( const { default: migrationFn } = await import(
path.join(migrationsDir, migration) path.join(migrationsDir, migration)
); );
const newSettings = await migrationFn(migrated); const newSettings = await migrationFn(structuredClone(migrated));
if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) { if (JSON.stringify(migrated) !== JSON.stringify(newSettings)) {
logger.debug(`Migration '${migration}' has been applied.`, { logger.debug(`Migration '${migration}' has been applied.`, {
label: 'Settings Migrator', label: 'Settings Migrator',
@@ -45,10 +44,20 @@ export const runMigrations = async (
} }
migrated = newSettings; migrated = newSettings;
} catch (e) { } catch (e) {
logger.error(`Error while running migration '${migration}'`, { // we stop jellyseerr if the migration failed
label: 'Settings Migrator', logger.error(
}); `Error while running migration '${migration}': ${e.message}`,
throw e; {
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();
} }
} }
@@ -72,22 +81,18 @@ export const runMigrations = async (
await fs.writeFile(BACKUP_PATH, oldBackup.toString()); await fs.writeFile(BACKUP_PATH, oldBackup.toString());
} }
} catch (e) { } catch (e) {
// we stop jellyseerr if the migration failed
logger.error( logger.error(
`Something went wrong while running settings migrations: ${e.message}`, `Something went wrong while running settings migrations: ${e.message}`,
{ label: 'Settings Migrator' } {
label: 'Settings Migrator',
}
); );
// we stop jellyseerr if the migration failed logger.error(
console.log( 'A common cause for this issue is a permission error of your configuration folder.',
'====================================================================' {
); label: 'Settings Migrator',
console.log( }
' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS '
);
console.log(
' Please check that your configuration folder is properly set up '
);
console.log(
'===================================================================='
); );
process.exit(); process.exit();
} }

View File

@@ -87,7 +87,7 @@ authRoutes.post('/plex', async (req, res, next) => {
}); });
settings.main.mediaServerType = MediaServerType.PLEX; settings.main.mediaServerType = MediaServerType.PLEX;
settings.save(); await settings.save();
startJobs(); startJobs();
await userRepository.save(user); await userRepository.save(user);
@@ -299,54 +299,84 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id }, 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 // Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) { if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin); throw new ApiError(403, ApiErrorCode.NotAdmin);
} }
logger.info( if (
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr', body.serverType !== MediaServerType.JELLYFIN &&
{ body.serverType !== MediaServerType.EMBY
label: 'API', ) {
ip: req.ip, throw new Error('select_server_type');
}
settings.main.mediaServerType = body.serverType;
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,
}
);
// 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, 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,
}
);
// 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;
// User doesn't exist, and there are no users in the database, we'll create the user await userRepository.save(user);
// with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
user = new User({
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: UserType.EMBY,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
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: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
} }
// Create an API key on Jellyfin from this admin user // Create an API key on Jellyfin from this admin user
@@ -366,10 +396,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.urlBase = body.urlBase ?? ''; settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false; settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey; settings.jellyfin.apiKey = apiKey;
settings.save(); await settings.save();
startJobs(); startJobs();
await userRepository.save(user);
} }
// User already exists, let's update their information // User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) { else if (account.User.Id === user?.jellyfinUserId) {

View File

@@ -69,19 +69,19 @@ settingsRoutes.get('/main', (req, res, next) => {
res.status(200).json(filteredMainSettings(req.user, settings.main)); res.status(200).json(filteredMainSettings(req.user, settings.main));
}); });
settingsRoutes.post('/main', (req, res) => { settingsRoutes.post('/main', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.main = merge(settings.main, req.body); settings.main = merge(settings.main, req.body);
settings.save(); await settings.save();
return res.status(200).json(settings.main); 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 settings = getSettings();
const main = settings.regenerateApiKey(); const main = await settings.regenerateApiKey();
if (!req.user) { if (!req.user) {
return next({ status: 500, message: 'User missing from request.' }); return next({ status: 500, message: 'User missing from request.' });
@@ -118,7 +118,7 @@ settingsRoutes.post('/plex', async (req, res, next) => {
settings.plex.machineId = result.MediaContainer.machineIdentifier; settings.plex.machineId = result.MediaContainer.machineIdentifier;
settings.plex.name = result.MediaContainer.friendlyName; settings.plex.name = result.MediaContainer.friendlyName;
settings.save(); await settings.save();
} catch (e) { } catch (e) {
logger.error('Something went wrong testing Plex connection', { logger.error('Something went wrong testing Plex connection', {
label: 'API', label: 'API',
@@ -231,7 +231,7 @@ settingsRoutes.get('/plex/library', async (req, res) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
settings.save(); await settings.save();
return res.status(200).json(settings.plex.libraries); return res.status(200).json(settings.plex.libraries);
}); });
@@ -282,7 +282,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
Object.assign(settings.jellyfin, req.body); Object.assign(settings.jellyfin, req.body);
settings.jellyfin.serverId = result.Id; settings.jellyfin.serverId = result.Id;
settings.jellyfin.name = result.ServerName; settings.jellyfin.name = result.ServerName;
settings.save(); await settings.save();
} catch (e) { } catch (e) {
if (e instanceof ApiError) { if (e instanceof ApiError) {
logger.error('Something went wrong testing Jellyfin connection', { logger.error('Something went wrong testing Jellyfin connection', {
@@ -370,7 +370,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
...library, ...library,
enabled: enabledLibraries.includes(library.id), enabled: enabledLibraries.includes(library.id),
})); }));
settings.save(); await settings.save();
return res.status(200).json(settings.jellyfin.libraries); return res.status(200).json(settings.jellyfin.libraries);
}); });
@@ -434,7 +434,7 @@ settingsRoutes.post('/tautulli', async (req, res, next) => {
throw new Error('Tautulli version not supported'); throw new Error('Tautulli version not supported');
} }
settings.save(); await settings.save();
} catch (e) { } catch (e) {
logger.error('Something went wrong testing Tautulli connection', { logger.error('Something went wrong testing Tautulli connection', {
label: 'API', label: 'API',
@@ -695,7 +695,7 @@ settingsRoutes.post<{ jobId: JobId }>(
settingsRoutes.post<{ jobId: JobId }>( settingsRoutes.post<{ jobId: JobId }>(
'/jobs/:jobId/schedule', '/jobs/:jobId/schedule',
(req, res, next) => { async (req, res, next) => {
const scheduledJob = scheduledJobs.find( const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId (job) => job.id === req.params.jobId
); );
@@ -709,7 +709,7 @@ settingsRoutes.post<{ jobId: JobId }>(
if (result) { if (result) {
settings.jobs[scheduledJob.id].schedule = req.body.schedule; settings.jobs[scheduledJob.id].schedule = req.body.schedule;
settings.save(); await settings.save();
scheduledJob.cronSchedule = req.body.schedule; scheduledJob.cronSchedule = req.body.schedule;
@@ -766,11 +766,11 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
settingsRoutes.post( settingsRoutes.post(
'/initialize', '/initialize',
isAuthenticated(Permission.ADMIN), isAuthenticated(Permission.ADMIN),
(_req, res) => { async (_req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.public.initialized = true; settings.public.initialized = true;
settings.save(); await settings.save();
return res.status(200).json(settings.public); return res.status(200).json(settings.public);
} }

View File

@@ -31,11 +31,11 @@ notificationRoutes.get('/discord', (_req, res) => {
res.status(200).json(settings.notifications.agents.discord); res.status(200).json(settings.notifications.agents.discord);
}); });
notificationRoutes.post('/discord', (req, res) => { notificationRoutes.post('/discord', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.discord = req.body; settings.notifications.agents.discord = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.discord); 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); res.status(200).json(settings.notifications.agents.slack);
}); });
notificationRoutes.post('/slack', (req, res) => { notificationRoutes.post('/slack', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.slack = req.body; settings.notifications.agents.slack = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.slack); 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); res.status(200).json(settings.notifications.agents.telegram);
}); });
notificationRoutes.post('/telegram', (req, res) => { notificationRoutes.post('/telegram', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.telegram = req.body; settings.notifications.agents.telegram = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.telegram); 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); res.status(200).json(settings.notifications.agents.pushbullet);
}); });
notificationRoutes.post('/pushbullet', (req, res) => { notificationRoutes.post('/pushbullet', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushbullet = req.body; settings.notifications.agents.pushbullet = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.pushbullet); 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); res.status(200).json(settings.notifications.agents.pushover);
}); });
notificationRoutes.post('/pushover', (req, res) => { notificationRoutes.post('/pushover', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.pushover = req.body; settings.notifications.agents.pushover = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.pushover); 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); res.status(200).json(settings.notifications.agents.email);
}); });
notificationRoutes.post('/email', (req, res) => { notificationRoutes.post('/email', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.email = req.body; settings.notifications.agents.email = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.email); 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); res.status(200).json(settings.notifications.agents.webpush);
}); });
notificationRoutes.post('/webpush', (req, res) => { notificationRoutes.post('/webpush', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.webpush = req.body; settings.notifications.agents.webpush = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.webpush); res.status(200).json(settings.notifications.agents.webpush);
}); });
@@ -284,7 +284,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
res.status(200).json(response); res.status(200).json(response);
}); });
notificationRoutes.post('/webhook', (req, res, next) => { notificationRoutes.post('/webhook', async (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
try { try {
JSON.parse(req.body.options.jsonPayload); JSON.parse(req.body.options.jsonPayload);
@@ -300,7 +300,7 @@ notificationRoutes.post('/webhook', (req, res, next) => {
authHeader: req.body.options.authHeader, authHeader: req.body.options.authHeader,
}, },
}; };
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.webhook); res.status(200).json(settings.notifications.agents.webhook);
} catch (e) { } catch (e) {
@@ -351,11 +351,11 @@ notificationRoutes.get('/lunasea', (_req, res) => {
res.status(200).json(settings.notifications.agents.lunasea); res.status(200).json(settings.notifications.agents.lunasea);
}); });
notificationRoutes.post('/lunasea', (req, res) => { notificationRoutes.post('/lunasea', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.lunasea = req.body; settings.notifications.agents.lunasea = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.lunasea); 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); res.status(200).json(settings.notifications.agents.gotify);
}); });
notificationRoutes.post('/gotify', (req, res) => { notificationRoutes.post('/gotify', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
settings.notifications.agents.gotify = req.body; settings.notifications.agents.gotify = req.body;
settings.save(); await settings.save();
res.status(200).json(settings.notifications.agents.gotify); res.status(200).json(settings.notifications.agents.gotify);
}); });

View File

@@ -12,7 +12,7 @@ radarrRoutes.get('/', (_req, res) => {
res.status(200).json(settings.radarr); res.status(200).json(settings.radarr);
}); });
radarrRoutes.post('/', (req, res) => { radarrRoutes.post('/', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
const newRadarr = req.body as RadarrSettings; const newRadarr = req.body as RadarrSettings;
@@ -31,7 +31,7 @@ radarrRoutes.post('/', (req, res) => {
} }
settings.radarr = [...settings.radarr, newRadarr]; settings.radarr = [...settings.radarr, newRadarr];
settings.save(); await settings.save();
return res.status(201).json(newRadarr); return res.status(201).json(newRadarr);
}); });
@@ -76,7 +76,7 @@ radarrRoutes.post<
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>( radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
'/:id', '/:id',
(req, res, next) => { async (req, res, next) => {
const settings = getSettings(); const settings = getSettings();
const radarrIndex = settings.radarr.findIndex( const radarrIndex = settings.radarr.findIndex(
@@ -102,7 +102,7 @@ radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
...req.body, ...req.body,
id: Number(req.params.id), id: Number(req.params.id),
} as RadarrSettings; } as RadarrSettings;
settings.save(); await settings.save();
return res.status(200).json(settings.radarr[radarrIndex]); 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 settings = getSettings();
const radarrIndex = settings.radarr.findIndex( const radarrIndex = settings.radarr.findIndex(
@@ -146,7 +146,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
} }
const removed = settings.radarr.splice(radarrIndex, 1); const removed = settings.radarr.splice(radarrIndex, 1);
settings.save(); await settings.save();
return res.status(200).json(removed[0]); return res.status(200).json(removed[0]);
}); });

View File

@@ -12,7 +12,7 @@ sonarrRoutes.get('/', (_req, res) => {
res.status(200).json(settings.sonarr); res.status(200).json(settings.sonarr);
}); });
sonarrRoutes.post('/', (req, res) => { sonarrRoutes.post('/', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
const newSonarr = req.body as SonarrSettings; const newSonarr = req.body as SonarrSettings;
@@ -31,7 +31,7 @@ sonarrRoutes.post('/', (req, res) => {
} }
settings.sonarr = [...settings.sonarr, newSonarr]; settings.sonarr = [...settings.sonarr, newSonarr];
settings.save(); await settings.save();
return res.status(201).json(newSonarr); return res.status(201).json(newSonarr);
}); });
@@ -73,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 settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex( const sonarrIndex = settings.sonarr.findIndex(
@@ -101,12 +101,12 @@ sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
...req.body, ...req.body,
id: Number(req.params.id), id: Number(req.params.id),
} as SonarrSettings; } as SonarrSettings;
settings.save(); await settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]); 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 settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex( const sonarrIndex = settings.sonarr.findIndex(
@@ -120,7 +120,7 @@ sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
} }
const removed = settings.sonarr.splice(sonarrIndex, 1); const removed = settings.sonarr.splice(sonarrIndex, 1);
settings.save(); await settings.save();
return res.status(200).json(removed[0]); return res.status(200).json(removed[0]);
}); });

View File

@@ -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;
}

View File

@@ -14,7 +14,7 @@ class RestartFlag {
return ( return (
this.settings.csrfProtection !== settings.csrfProtection || this.settings.csrfProtection !== settings.csrfProtection ||
this.settings.trustProxy !== settings.trustProxy || this.settings.trustProxy !== settings.trustProxy ||
this.settings.httpProxy !== settings.httpProxy this.settings.proxy.enabled !== settings.proxy.enabled
); );
} }
} }

View File

@@ -55,8 +55,17 @@ const messages = defineMessages('components.Settings.SettingsMain', {
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests', partialRequestsEnabled: 'Allow Partial Series Requests',
locale: 'Display Language', locale: 'Display Language',
httpProxy: 'HTTP Proxy', proxyEnabled: 'HTTP(S) Proxy',
httpProxyTip: 'Tooltip to write', 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 = () => { const SettingsMain = () => {
@@ -84,9 +93,12 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationUrlTrailingSlash), intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/') (value) => !value || !value.endsWith('/')
), ),
httpProxy: Yup.string().url( proxyPort: Yup.number().when('proxyEnabled', {
intl.formatMessage(messages.validationApplicationUrl) is: (proxyEnabled: boolean) => proxyEnabled,
), then: Yup.number().required(
intl.formatMessage(messages.validationProxyPort)
),
}),
}); });
const regenerate = async () => { const regenerate = async () => {
@@ -142,7 +154,14 @@ const SettingsMain = () => {
partialRequestsEnabled: data?.partialRequestsEnabled, partialRequestsEnabled: data?.partialRequestsEnabled,
trustProxy: data?.trustProxy, trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages, cacheImages: data?.cacheImages,
httpProxy: data?.httpProxy, 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 enableReinitialize
validationSchema={MainSettingsSchema} validationSchema={MainSettingsSchema}
@@ -164,7 +183,16 @@ const SettingsMain = () => {
partialRequestsEnabled: values.partialRequestsEnabled, partialRequestsEnabled: values.partialRequestsEnabled,
trustProxy: values.trustProxy, trustProxy: values.trustProxy,
cacheImages: values.cacheImages, cacheImages: values.cacheImages,
httpProxy: values.httpProxy, 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(); if (!res.ok) throw new Error();
@@ -445,27 +473,175 @@ const SettingsMain = () => {
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<label htmlFor="httpProxy" className="checkbox-label"> <label htmlFor="proxyEnabled" className="checkbox-label">
<span className="mr-2"> <span className="mr-2">
{intl.formatMessage(messages.httpProxy)} {intl.formatMessage(messages.proxyEnabled)}
</span> </span>
<SettingsBadge badgeType="advanced" className="mr-2" /> <SettingsBadge badgeType="advanced" className="mr-2" />
<SettingsBadge badgeType="restartRequired" /> <SettingsBadge badgeType="restartRequired" />
<span className="label-tip">
{intl.formatMessage(messages.httpProxyTip)}
</span>
</label> </label>
<div className="form-input-area"> <div className="form-input-area">
<div className="form-input-field"> <Field
<Field id="httpProxy" name="httpProxy" type="text" /> type="checkbox"
</div> id="proxyEnabled"
{errors.httpProxy && name="proxyEnabled"
touched.httpProxy && onChange={() => {
typeof errors.httpProxy === 'string' && ( setFieldValue('proxyEnabled', !values.proxyEnabled);
<div className="error">{errors.httpProxy}</div> }}
)} />
</div> </div>
</div> </div>
{values.proxyEnabled && (
<>
<div className="form-row">
<label htmlFor="proxyHostname" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyHostname)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyHostname"
name="proxyHostname"
type="text"
/>
</div>
{errors.proxyHostname &&
touched.proxyHostname &&
typeof errors.proxyHostname === 'string' && (
<div className="error">{errors.proxyHostname}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPort" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPort)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyPort" name="proxyPort" type="text" />
</div>
{errors.proxyPort &&
touched.proxyPort &&
typeof errors.proxyPort === 'string' && (
<div className="error">{errors.proxyPort}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxySsl" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxySsl)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxySsl"
name="proxySsl"
onChange={() => {
setFieldValue('proxySsl', !values.proxySsl);
}}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="proxyUser" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyUser)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="proxyUser" name="proxyUser" type="text" />
</div>
{errors.proxyUser &&
touched.proxyUser &&
typeof errors.proxyUser === 'string' && (
<div className="error">{errors.proxyUser}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="proxyPassword" className="checkbox-label">
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyPassword)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyPassword"
name="proxyPassword"
type="password"
/>
</div>
{errors.proxyPassword &&
touched.proxyPassword &&
typeof errors.proxyPassword === 'string' && (
<div className="error">{errors.proxyPassword}</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassFilter"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(messages.proxyBypassFilter)}
</span>
<span className="label-tip ml-4">
{intl.formatMessage(messages.proxyBypassFilterTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="proxyBypassFilter"
name="proxyBypassFilter"
type="text"
/>
</div>
{errors.proxyBypassFilter &&
touched.proxyBypassFilter &&
typeof errors.proxyBypassFilter === 'string' && (
<div className="error">
{errors.proxyBypassFilter}
</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="proxyBypassLocalAddresses"
className="checkbox-label"
>
<span className="mr-2 ml-4">
{intl.formatMessage(
messages.proxyBypassLocalAddresses
)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="proxyBypassLocalAddresses"
name="proxyBypassLocalAddresses"
onChange={() => {
setFieldValue(
'proxyBypassLocalAddresses',
!values.proxyBypassLocalAddresses
);
}}
/>
</div>
</div>
</>
)}
<div className="actions"> <div className="actions">
<div className="flex justify-end"> <div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm"> <span className="ml-3 inline-flex rounded-md shadow-sm">