diff --git a/docs/docs/administration/system-settings.md b/docs/docs/administration/system-settings.md index 7dc9c08db3..731b104eec 100644 --- a/docs/docs/administration/system-settings.md +++ b/docs/docs/administration/system-settings.md @@ -168,7 +168,11 @@ Override the default notifications text with notification templates. More inform ### External Domain -Overrides the domain name in shared links and email notifications. The URL should not include a trailing slash. +Overrides the domain name in email notifications. The URL should not include a trailing slash. + +### Shared Link Domain + +Overrides the domain name in shared links. The URL should not include a trailing slash. ### Welcome Message diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index 5c34acdd9d..bfb37662c8 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -217,6 +217,7 @@ The default configuration looks like this: }, "server": { "externalDomain": "", + "sharedLinkDomain": "", "loginPageMessage": "", "publicUsers": true }, diff --git a/e2e/src/specs/server/api/server.e2e-spec.ts b/e2e/src/specs/server/api/server.e2e-spec.ts index 53857a31fa..f6ede25a7e 100644 --- a/e2e/src/specs/server/api/server.e2e-spec.ts +++ b/e2e/src/specs/server/api/server.e2e-spec.ts @@ -138,6 +138,7 @@ describe('/server', () => { userDeleteDelay: 7, isInitialized: true, externalDomain: '', + sharedLinkDomain: '', publicUsers: true, isOnboarded: false, maintenanceMode: false, diff --git a/e2e/src/ui/mock-network/base-network.ts b/e2e/src/ui/mock-network/base-network.ts index af8d1dbfef..6acaaf5750 100644 --- a/e2e/src/ui/mock-network/base-network.ts +++ b/e2e/src/ui/mock-network/base-network.ts @@ -148,6 +148,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI isInitialized: true, isOnboarded: true, externalDomain: '', + sharedLinkDomain: '', publicUsers: true, mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', diff --git a/i18n/da.json b/i18n/da.json index 9daed30b52..29dd608dfa 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -346,6 +346,8 @@ "server_public_users_description": "Alle brugere (navn og e-mail) vises, når en bruger tilføjes til delte album. Når den er deaktiveret, vil brugerlisten kun være tilgængelig for administratorbrugere.", "server_settings": "Serverindstillinger", "server_settings_description": "Administrér serverindstillinger", + "server_shared_link_domain_settings": "Delt link domæne", + "server_shared_link_domain_settings_description": "Domæne brugt til delte links", "server_stats_page_description": "Admin server statistikside", "server_welcome_message": "Velkomstbesked", "server_welcome_message_description": "En besked som bliver vist på loginsiden.", diff --git a/i18n/en.json b/i18n/en.json index 536b7677ac..f3bc70cad1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -347,6 +347,8 @@ "server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.", "server_settings": "Server Settings", "server_settings_description": "Manage server settings", + "server_shared_link_domain_settings": "Shared link domain", + "server_shared_link_domain_settings_description": "Domain used for shared links", "server_stats_page_description": "Admin server statistics page", "server_welcome_message": "Welcome message", "server_welcome_message_description": "A message that is displayed on the login page.", diff --git a/i18n/en_GB.json b/i18n/en_GB.json index a077b444d3..18124d6b94 100644 --- a/i18n/en_GB.json +++ b/i18n/en_GB.json @@ -346,6 +346,8 @@ "server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.", "server_settings": "Server Settings", "server_settings_description": "Manage server settings", + "server_shared_link_domain_settings": "Shared link domain", + "server_shared_link_domain_settings_description": "Domain used for shared links", "server_stats_page_description": "Admin server statistics page", "server_welcome_message": "Welcome message", "server_welcome_message_description": "A message that is displayed on the login page.", diff --git a/mobile/lib/models/server_info/server_config.model.dart b/mobile/lib/models/server_info/server_config.model.dart index 15bbe41485..3c92f7c968 100644 --- a/mobile/lib/models/server_info/server_config.model.dart +++ b/mobile/lib/models/server_info/server_config.model.dart @@ -4,6 +4,7 @@ class ServerConfig { final int trashDays; final String oauthButtonText; final String externalDomain; + final String sharedLinkDomain; final String mapDarkStyleUrl; final String mapLightStyleUrl; @@ -11,15 +12,17 @@ class ServerConfig { required this.trashDays, required this.oauthButtonText, required this.externalDomain, + required this.sharedLinkDomain, required this.mapDarkStyleUrl, required this.mapLightStyleUrl, }); - ServerConfig copyWith({int? trashDays, String? oauthButtonText, String? externalDomain}) { + ServerConfig copyWith({int? trashDays, String? oauthButtonText, String? externalDomain, String? sharedLinkDomain}) { return ServerConfig( trashDays: trashDays ?? this.trashDays, oauthButtonText: oauthButtonText ?? this.oauthButtonText, externalDomain: externalDomain ?? this.externalDomain, + sharedLinkDomain: sharedLinkDomain ?? this.sharedLinkDomain, mapDarkStyleUrl: mapDarkStyleUrl, mapLightStyleUrl: mapLightStyleUrl, ); @@ -27,12 +30,13 @@ class ServerConfig { @override String toString() => - 'ServerConfig(trashDays: $trashDays, oauthButtonText: $oauthButtonText, externalDomain: $externalDomain)'; + 'ServerConfig(trashDays: $trashDays, oauthButtonText: $oauthButtonText, externalDomain: $externalDomain, sharedLinkDomain: $sharedLinkDomain)'; ServerConfig.fromDto(ServerConfigDto dto) : trashDays = dto.trashDays, oauthButtonText = dto.oauthButtonText, externalDomain = dto.externalDomain, + sharedLinkDomain = dto.sharedLinkDomain, mapDarkStyleUrl = dto.mapDarkStyleUrl, mapLightStyleUrl = dto.mapLightStyleUrl; @@ -44,9 +48,11 @@ class ServerConfig { return other.trashDays == trashDays && other.oauthButtonText == oauthButtonText && - other.externalDomain == externalDomain; + other.externalDomain == externalDomain && + other.sharedLinkDomain == sharedLinkDomain; } @override - int get hashCode => trashDays.hashCode ^ oauthButtonText.hashCode ^ externalDomain.hashCode; + int get hashCode => + trashDays.hashCode ^ oauthButtonText.hashCode ^ externalDomain.hashCode ^ sharedLinkDomain.hashCode; } diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 6c90571428..74a98bcb5c 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -31,8 +31,8 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final themeData = context.themeData; final colorScheme = context.colorScheme; - final externalDomain = ref.watch(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); - final displayServerUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); + final sharedLinkDomain = ref.watch(serverInfoProvider.select((s) => s.serverConfig.sharedLinkDomain)); + final displayServerUrl = sharedLinkDomain.isNotEmpty ? sharedLinkDomain : getServerUrl(); final expiryPresets = <(Duration, String)>[ (Duration.zero, context.t.never), (const Duration(minutes: 30), context.t.shared_link_edit_expire_after_option_minutes(count: 30)), @@ -345,9 +345,9 @@ class SharedLinkEditPage extends HookConsumerWidget { if (!context.mounted) { return; } - final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); + final sharedLinkDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.sharedLinkDomain)); - final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); + final serverUrl = sharedLinkDomain.isNotEmpty ? sharedLinkDomain : getServerUrl(); if (newLink != null) { newShareLink.value = buildSharedLinkUrl(baseUrl: serverUrl, slug: newLink.slug, key: newLink.key) ?? ''; diff --git a/mobile/lib/providers/server_info.provider.dart b/mobile/lib/providers/server_info.provider.dart index 79593cce95..b257c201ba 100644 --- a/mobile/lib/providers/server_info.provider.dart +++ b/mobile/lib/providers/server_info.provider.dart @@ -22,6 +22,7 @@ class ServerInfoNotifier extends StateNotifier { trashDays: 30, oauthButtonText: '', externalDomain: '', + sharedLinkDomain: '', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', ), diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index e92b2afd12..b87c4e19e6 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -32,7 +32,9 @@ final Map> openApiPatches = { 'mapLightStyleUrl': 'https://tiles.immich.cloud/v1/style/light.json', 'mapDarkStyleUrl': 'https://tiles.immich.cloud/v1/style/dark.json', 'minFaces': 3, + 'sharedLinkDomain': '', }, + 'SystemConfigServerDto': {'sharedLinkDomain': ''}, 'UserResponseDto': {'profileChangedAt': _now}, 'AssetResponseDto': {'visibility': 'timeline', 'createdAt': _now, 'isEdited': false}, 'UserAdminResponseDto': {'profileChangedAt': _now}, diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc3bf82094..9962495767 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -22526,6 +22526,10 @@ "description": "Whether public user registration is enabled", "type": "boolean" }, + "sharedLinkDomain": { + "description": "Shared link domain URL", + "type": "string" + }, "trashDays": { "description": "Number of days before trashed assets are permanently deleted", "maximum": 9007199254740991, @@ -22550,6 +22554,7 @@ "minFaces", "oauthButtonText", "publicUsers", + "sharedLinkDomain", "trashDays", "userDeleteDelay" ], @@ -26435,12 +26440,17 @@ "publicUsers": { "description": "Public users", "type": "boolean" + }, + "sharedLinkDomain": { + "description": "Shared link domain", + "type": "string" } }, "required": [ "externalDomain", "loginPageMessage", - "publicUsers" + "publicUsers", + "sharedLinkDomain" ], "type": "object" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3ac958ce2d..f623aa26a5 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2013,6 +2013,8 @@ export type ServerConfigDto = { oauthButtonText: string; /** Whether public user registration is enabled */ publicUsers: boolean; + /** Shared link domain URL */ + sharedLinkDomain: string; /** Number of days before trashed assets are permanently deleted */ trashDays: number; /** Delay in days before deleted users are permanently removed */ @@ -2581,6 +2583,8 @@ export type SystemConfigServerDto = { loginPageMessage: string; /** Public users */ publicUsers: boolean; + /** Shared link domain */ + sharedLinkDomain: string; }; export type SystemConfigStorageTemplateDto = { /** Enabled */ diff --git a/server/src/config.ts b/server/src/config.ts index 55304080a3..08ac74b7a8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -208,6 +208,7 @@ export type SystemConfig = { }; server: { externalDomain: string; + sharedLinkDomain: string; loginPageMessage: string; publicUsers: boolean; }; @@ -418,6 +419,7 @@ export const defaults = Object.freeze({ }, server: { externalDomain: '', + sharedLinkDomain: '', loginPageMessage: '', publicUsers: true, }, diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 0215558aab..1c7b6f3811 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -120,6 +120,7 @@ const ServerConfigSchema = z isInitialized: z.boolean().describe('Whether the server has been initialized'), isOnboarded: z.boolean().describe('Whether the admin has completed onboarding'), externalDomain: z.string().describe('External domain URL'), + sharedLinkDomain: z.string().describe('Shared link domain URL'), publicUsers: z.boolean().describe('Whether public user registration is enabled'), mapDarkStyleUrl: z.string().describe('Map dark style URL'), mapLightStyleUrl: z.string().describe('Map light style URL'), diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index a50b7abe87..33e2bf713d 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -300,6 +300,12 @@ const SystemConfigServerSchema = z error: 'External domain must be an empty string or a valid URL', }) .describe('External domain'), + sharedLinkDomain: z + .string() + .refine((url) => url.length === 0 || z.url().safeParse(url).success, { + error: 'Shared link domain must be an empty string or a valid URL', + }) + .describe('Shared link domain'), loginPageMessage: z.string().describe('Login page message'), publicUsers: configBool.describe('Public users'), }) diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index b4b1af35d6..9d28f595ae 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -165,6 +165,7 @@ describe(ServerService.name, () => { isInitialized: false, isOnboarded: false, externalDomain: '', + sharedLinkDomain: '', publicUsers: true, mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json', mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json', diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 57342f9509..81b8c92e8b 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -123,6 +123,7 @@ export class ServerService extends BaseService { isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, + sharedLinkDomain: config.server.sharedLinkDomain, publicUsers: config.server.publicUsers, mapDarkStyleUrl: config.map.darkStyle, mapLightStyleUrl: config.map.lightStyle, diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 41b1d5403c..4fda24f959 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -13,7 +13,7 @@ import { } from 'src/dtos/shared-link.dto'; import { Permission, SharedLinkType } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { getExternalDomain, OpenGraphTags } from 'src/utils/misc'; +import { getSharedLinkDomain, OpenGraphTags } from 'src/utils/misc'; @Injectable() export class SharedLinkService extends BaseService { @@ -232,7 +232,7 @@ export class SharedLinkService extends BaseService { return { title: sharedLink.album ? sharedLink.album.albumName : 'Public Share', description: sharedLink.description || `${assetCount} shared photos & videos`, - imageUrl: new URL(imagePath, getExternalDomain(config.server, defaultDomain)).href, + imageUrl: new URL(imagePath, getSharedLinkDomain(config.server, defaultDomain)).href, }; } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 08851da96a..241e08308f 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -181,6 +181,7 @@ const updatedConfig = Object.freeze({ }, server: { externalDomain: '', + sharedLinkDomain: '', loginPageMessage: '', publicUsers: true, }, @@ -425,6 +426,28 @@ describe(SystemConfigService.name, () => { }); } + const sharedLinkDomainTests = [ + { should: 'with a trailing slash', sharedLinkDomain: 'https://demo.immich.app/' }, + { should: 'without a trailing slash', sharedLinkDomain: 'https://demo.immich.app' }, + { should: 'with a port', sharedLinkDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' }, + { + should: 'with basic auth', + sharedLinkDomain: 'https://user:password@example.com:123', + result: 'https://user:password@example.com:123', + }, + ]; + + for (const { should, sharedLinkDomain, result } of sharedLinkDomainTests) { + it(`should normalize an external domain ${should}`, async () => { + mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' })); + const partialConfig = { server: { sharedLinkDomain } }; + mocks.systemMetadata.readFile.mockResolvedValue(JSON.stringify(partialConfig)); + + const config = await sut.getSystemConfig(); + expect(config.server.sharedLinkDomain).toEqual(result ?? 'https://demo.immich.app'); + }); + } + it('should warn for unknown options in yaml', async () => { mocks.config.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' })); const partialConfig = ` diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index a6073471d1..9953ced26c 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -126,6 +126,17 @@ const buildConfig = async (repos: RepoDeps) => { config.server.externalDomain = externalDomain; } + if (config.server.sharedLinkDomain.length > 0) { + const domain = new URL(config.server.sharedLinkDomain); + + const sharedLinkDomain = + domain.password && domain.username + ? `${domain.protocol}//${domain.username}:${domain.password}@${domain.host}` + : domain.origin; + + config.server.sharedLinkDomain = sharedLinkDomain; + } + if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) { config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec); } diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 0e7a9e6cdc..5a19cd2afd 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -53,6 +53,9 @@ export const getMethodNames = (instance: any) => { export const getExternalDomain = (server: SystemConfig['server'], defaultDomain = 'https://my.immich.app') => server.externalDomain || defaultDomain; +export const getSharedLinkDomain = (server: SystemConfig['server'], defaultDomain = 'https://my.immich.app') => + server.sharedLinkDomain || defaultDomain; + /** * @returns a list of strings representing the keys of the object in dot notation */ diff --git a/web/src/lib/services/shared-link.service.spec.ts b/web/src/lib/services/shared-link.service.spec.ts index e59b527245..8e9ad5328c 100644 --- a/web/src/lib/services/shared-link.service.spec.ts +++ b/web/src/lib/services/shared-link.service.spec.ts @@ -4,7 +4,7 @@ import { sharedLinkFactory } from '@test-data/factories/shared-link-factory'; vi.mock(import('$lib/managers/server-config-manager.svelte'), () => ({ serverConfigManager: { - value: { externalDomain: 'http://localhost:2283' } as ServerConfigDto, + value: { sharedLinkDomain: 'http://localhost:2283' } as ServerConfigDto, init: vi.fn(), loadServerConfig: vi.fn(), }, diff --git a/web/src/lib/services/shared-link.service.ts b/web/src/lib/services/shared-link.service.ts index 885caee7d8..fc77b22ee9 100644 --- a/web/src/lib/services/shared-link.service.ts +++ b/web/src/lib/services/shared-link.service.ts @@ -61,7 +61,7 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin export const asUrl = (sharedLink: SharedLinkResponseDto) => { const path = Route.viewSharedLink(sharedLink); - return new URL(path, serverConfigManager.value.externalDomain || location.origin).href; + return new URL(path, serverConfigManager.value.sharedLinkDomain || location.origin).href; }; export const handleCreateSharedLink = async (dto: SharedLinkCreateDto) => { diff --git a/web/src/routes/admin/system-settings/ServerSettings.svelte b/web/src/routes/admin/system-settings/ServerSettings.svelte index 45be7733e3..0ed8a8ff22 100644 --- a/web/src/routes/admin/system-settings/ServerSettings.svelte +++ b/web/src/routes/admin/system-settings/ServerSettings.svelte @@ -25,6 +25,14 @@ isEdited={configToEdit.server.externalDomain !== config.server.externalDomain} /> + +