From 6fd39767dbe7dfc9ae9b5ba0ac95de434a7494ea Mon Sep 17 00:00:00 2001 From: midzelis Date: Wed, 22 Oct 2025 22:17:05 +0000 Subject: [PATCH] feat: add configurable API server URLs and CORS settings for development Adds environment variables to configure API server URLs and CORS settings for development environments, enabling flexible multi-server setups and cross-origin testing. --- pnpm-lock.yaml | 19 +++++++++++ server/src/dtos/env.dto.ts | 6 ++++ server/src/repositories/config.repository.ts | 33 ++++++++++++++++++ server/src/workers/api.ts | 12 ++++++- web/package.json | 1 + web/src/lib/utils/server.ts | 35 +++++++++++++++++++- web/vite.config.ts | 11 ++++++ 7 files changed, 115 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be6b2974b0..59a49e6608 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -789,6 +789,9 @@ importers: '@koddsson/eslint-plugin-tscompat': specifier: ^0.2.0 version: 0.2.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) + '@rollup/plugin-replace': + specifier: ^6.0.2 + version: 6.0.2(rollup@4.52.5) '@socket.io/component-emitter': specifier: ^3.1.0 version: 3.1.2 @@ -3681,6 +3684,15 @@ packages: peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@rollup/plugin-replace@6.0.2': + resolution: {integrity: sha512-7QaYCf8bqF04dOy7w/eHmJeNExxTYwvKAmlSAH/EaWWUzbT0h5sbF6bktFoX/0F/0qwng5/dWFMyf3gzaM8DsQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} engines: {node: '>=14.0.0'} @@ -15290,6 +15302,13 @@ snapshots: dependencies: react: 19.2.0 + '@rollup/plugin-replace@6.0.2(rollup@4.52.5)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.52.5) + magic-string: 0.30.19 + optionalDependencies: + rollup: 4.52.5 + '@rollup/pluginutils@5.3.0(rollup@4.52.5)': dependencies: '@types/estree': 1.0.8 diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 3543d8dae9..db6a747f7e 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -195,4 +195,10 @@ export class EnvDto { @IsString() @Optional() REDIS_URL?: string; + + @ValidateBoolean({ optional: true }) + IMMICH_DEV_CORS_ALL_ORIGINS?: boolean; + + @ValidateBoolean({ optional: true }) + IMMICH_DEV_CORS_CREDENTIALS?: boolean; } diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d5c279099c..80d5694f10 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -1,5 +1,6 @@ import { RegisterQueueOptions } from '@nestjs/bullmq'; import { Inject, Injectable, Optional } from '@nestjs/common'; +import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; import { QueueOptions } from 'bullmq'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; @@ -30,6 +31,13 @@ export interface EnvData { configFile?: string; logLevel?: LogLevel; + dev: { + cors: { + allOrigins?: boolean; + credentials?: boolean; + }; + }; + buildMetadata: { build?: string; buildUrl?: string; @@ -222,6 +230,13 @@ const getEnv = (): EnvData => { configFile: dto.IMMICH_CONFIG_FILE, logLevel: dto.IMMICH_LOG_LEVEL, + dev: { + cors: { + allOrigins: dto.IMMICH_DEV_CORS_ALL_ORIGINS, + credentials: dto.IMMICH_DEV_CORS_CREDENTIALS, + }, + }, + buildMetadata: { build: dto.IMMICH_BUILD, buildUrl: dto.IMMICH_BUILD_URL, @@ -342,6 +357,24 @@ export class ConfigRepository { return this.getEnv().environment === ImmichEnvironment.Development; } + getCorsOptions(): CorsOptions | undefined { + const options: Partial = {}; + const env = this.getEnv(); + + if (env.dev.cors.allOrigins) { + options.origin = (requestOrigin, callback) => { + callback(null, requestOrigin); + }; + } + if (env.dev.cors.credentials) { + options.credentials = env.dev.cors.credentials; + } + if (Object.keys(options).length > 0) { + return options; + } + return undefined; + } + getWorker() { return this.worker; } diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index f56adf3b68..6393aed914 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -34,7 +34,17 @@ async function bootstrap() { app.use(cookieParser()); app.use(json({ limit: '10mb' })); if (configRepository.isDev()) { - app.enableCors(); + const options = configRepository.getCorsOptions(); + if (options) { + logger.warn(`Enabling CORS: ${JSON.stringify(configRepository.getEnv().dev.cors)}`); + logger.warn( + 'NOTE: to properly support a fully statically hosted frontend you MUST configure the frontend/backend to be on the same site. i.e. frontend=https://localhost:1234 and backend=http://localhost:2283 or configure TLS', + ); + app.enableCors(options); + } else { + logger.warn('Enabling CORS'); + app.enableCors(); + } } app.useWebSocketAdapter(new WebSocketAdapter(app)); useSwagger(app, { write: configRepository.isDev() }); diff --git a/web/package.json b/web/package.json index 1096f8a199..7e42a04da5 100644 --- a/web/package.json +++ b/web/package.json @@ -65,6 +65,7 @@ "@eslint/js": "^9.36.0", "@faker-js/faker": "^10.0.0", "@koddsson/eslint-plugin-tscompat": "^0.2.0", + "@rollup/plugin-replace": "^6.0.2", "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/enhanced-img": "^0.8.0", diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts index 1c52274d23..b7418ecef4 100644 --- a/web/src/lib/utils/server.ts +++ b/web/src/lib/utils/server.ts @@ -1,15 +1,48 @@ import { retrieveServerConfig } from '$lib/stores/server-config.store'; -import { initLanguage } from '$lib/utils'; +import { AbortError, initLanguage, sleep } from '$lib/utils'; import { defaults } from '@immich/sdk'; import { memoize } from 'lodash-es'; type Fetch = typeof fetch; +const api_server: string = '@IMMICH_API_SERVER@'; + +const tryServers = async (fetchFn: typeof fetch) => { + const servers = api_server + .split(',') + .map((v) => v.trim()) + .filter((v) => v !== ''); + if (servers.length === 0) { + return true; + } + // servers are in priority order, try in parallel, use first success + const fetchers = servers.map(async (url: string) => { + const response = await fetchFn(url + '/server/config'); + if (response.ok) { + return url; + } + throw new AbortError(); + }); + try { + const urlWinner = await Promise.any(fetchers); + defaults.baseUrl = urlWinner; + defaults.fetch = (url, options) => fetchFn(url, { credentials: 'include', ...options }); + } catch (e) { + console.error(e); + return false; + } +}; + async function _init(fetch: Fetch) { // set event.fetch on the fetch-client used by @immich/sdk // https://kit.svelte.dev/docs/load#making-fetch-requests // https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options defaults.fetch = fetch; + try { + await Promise.race([tryServers(fetch), sleep(5000)]); + } catch { + throw 'Could not connect to any server'; + } await initLanguage(); await retrieveServerConfig(); } diff --git a/web/vite.config.ts b/web/vite.config.ts index b44d1c0078..7c073c45fd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,3 +1,4 @@ +import replace from '@rollup/plugin-replace'; import { enhancedImages } from '@sveltejs/enhanced-img'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; @@ -39,6 +40,16 @@ export default defineConfig({ enhancedImages(), tailwindcss(), sveltekit(), + replace({ + preventAssignment: true, + include: ['**/server.ts'], + sourceMap: true, + objectGuards: false, + delimiters: ['@', '@'], + values: { + IMMICH_API_SERVER: process.env.IMMICH_API_SERVER ?? '', + }, + }), process.env.BUILD_STATS === 'true' ? visualizer({ emitFile: true,