feat: separate shared link domain input

This commit is contained in:
Aram Al-Sabti 2026-07-28 19:02:27 +02:00
parent 4609adbb6d
commit db5ef0f95b
24 changed files with 111 additions and 13 deletions

View file

@ -213,6 +213,7 @@ The default configuration looks like this:
},
"server": {
"externalDomain": "",
"sharedLinkDomain": "",
"loginPageMessage": "",
"publicUsers": true
},

View file

@ -138,6 +138,7 @@ describe('/server', () => {
userDeleteDelay: 7,
isInitialized: true,
externalDomain: '',
sharedLinkDomain: '',
publicUsers: true,
isOnboarded: false,
maintenanceMode: false,

View file

@ -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',

View file

@ -342,6 +342,8 @@
"send_welcome_email": "Send velkomst-email",
"server_external_domain_settings": "Eksternt domæne",
"server_external_domain_settings_description": "Domæne brugt til eksterne links",
"server_shared_link_domain_settings": "Delt link domæne",
"server_shared_link_domain_settings_description": "Domæne brugt til delte links",
"server_public_users": "Offentlige brugere",
"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",

View file

@ -342,6 +342,8 @@
"send_welcome_email": "Send welcome email",
"server_external_domain_settings": "External domain",
"server_external_domain_settings_description": "Domain used for external links",
"server_shared_link_domain_settings": "Shared link domain",
"server_shared_link_domain_settings_description": "Domain used for shared links",
"server_public_users": "Public Users",
"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",

View file

@ -342,6 +342,8 @@
"send_welcome_email": "Send welcome email",
"server_external_domain_settings": "External domain",
"server_external_domain_settings_description": "Domain used for external links",
"server_shared_link_domain_settings": "Shared link domain",
"server_shared_link_domain_settings_description": "Domain used for shared links",
"server_public_users": "Public Users",
"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",

View file

@ -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) ?? '';

View file

@ -20,6 +20,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
trashDays: 30,
oauthButtonText: '',
externalDomain: '',
sharedLinkDomain: '',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
),

View file

@ -23,6 +23,7 @@ class ServerConfigDto {
required this.minFaces,
required this.oauthButtonText,
required this.publicUsers,
required this.sharedLinkDomain,
required this.trashDays,
required this.userDeleteDelay,
});
@ -60,6 +61,9 @@ class ServerConfigDto {
/// Whether public user registration is enabled
bool publicUsers;
/// Shared link domain URL
String sharedLinkDomain;
/// Number of days before trashed assets are permanently deleted
///
/// Minimum value: -9007199254740991
@ -84,6 +88,7 @@ class ServerConfigDto {
other.minFaces == minFaces &&
other.oauthButtonText == oauthButtonText &&
other.publicUsers == publicUsers &&
other.sharedLinkDomain == sharedLinkDomain &&
other.trashDays == trashDays &&
other.userDeleteDelay == userDeleteDelay;
@ -100,11 +105,12 @@ class ServerConfigDto {
(minFaces.hashCode) +
(oauthButtonText.hashCode) +
(publicUsers.hashCode) +
(sharedLinkDomain.hashCode) +
(trashDays.hashCode) +
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, minFaces=$minFaces, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, minFaces=$minFaces, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, sharedLinkDomain=$sharedLinkDomain, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -118,6 +124,7 @@ class ServerConfigDto {
json[r'minFaces'] = this.minFaces;
json[r'oauthButtonText'] = this.oauthButtonText;
json[r'publicUsers'] = this.publicUsers;
json[r'sharedLinkDomain'] = this.sharedLinkDomain;
json[r'trashDays'] = this.trashDays;
json[r'userDeleteDelay'] = this.userDeleteDelay;
return json;
@ -142,6 +149,7 @@ class ServerConfigDto {
minFaces: mapValueOfType<int>(json, r'minFaces')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
sharedLinkDomain: mapValueOfType<String>(json, r'sharedLinkDomain')!,
trashDays: mapValueOfType<int>(json, r'trashDays')!,
userDeleteDelay: mapValueOfType<int>(json, r'userDeleteDelay')!,
);
@ -201,6 +209,7 @@ class ServerConfigDto {
'minFaces',
'oauthButtonText',
'publicUsers',
'sharedLinkDomain',
'trashDays',
'userDeleteDelay',
};

View file

@ -16,6 +16,7 @@ class SystemConfigServerDto {
required this.externalDomain,
required this.loginPageMessage,
required this.publicUsers,
required this.sharedLinkDomain,
});
/// External domain
@ -27,27 +28,33 @@ class SystemConfigServerDto {
/// Public users
bool publicUsers;
/// Shared link domain
String sharedLinkDomain;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigServerDto &&
other.externalDomain == externalDomain &&
other.loginPageMessage == loginPageMessage &&
other.publicUsers == publicUsers;
other.publicUsers == publicUsers &&
other.sharedLinkDomain == sharedLinkDomain;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(externalDomain.hashCode) +
(loginPageMessage.hashCode) +
(publicUsers.hashCode);
(publicUsers.hashCode) +
(sharedLinkDomain.hashCode);
@override
String toString() => 'SystemConfigServerDto[externalDomain=$externalDomain, loginPageMessage=$loginPageMessage, publicUsers=$publicUsers]';
String toString() => 'SystemConfigServerDto[externalDomain=$externalDomain, loginPageMessage=$loginPageMessage, publicUsers=$publicUsers, sharedLinkDomain=$sharedLinkDomain]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'externalDomain'] = this.externalDomain;
json[r'loginPageMessage'] = this.loginPageMessage;
json[r'publicUsers'] = this.publicUsers;
json[r'sharedLinkDomain'] = this.sharedLinkDomain;
return json;
}
@ -63,6 +70,7 @@ class SystemConfigServerDto {
externalDomain: mapValueOfType<String>(json, r'externalDomain')!,
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
sharedLinkDomain: mapValueOfType<String>(json, r'sharedLinkDomain')!,
);
}
return null;
@ -113,6 +121,7 @@ class SystemConfigServerDto {
'externalDomain',
'loginPageMessage',
'publicUsers',
'sharedLinkDomain',
};
}

View file

@ -22503,6 +22503,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,
@ -22527,6 +22531,7 @@
"minFaces",
"oauthButtonText",
"publicUsers",
"sharedLinkDomain",
"trashDays",
"userDeleteDelay"
],
@ -26412,12 +26417,17 @@
"publicUsers": {
"description": "Public users",
"type": "boolean"
},
"sharedLinkDomain": {
"description": "Shared link domain",
"type": "string"
}
},
"required": [
"externalDomain",
"loginPageMessage",
"publicUsers"
"publicUsers",
"sharedLinkDomain"
],
"type": "object"
},

View file

@ -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 */

View file

@ -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<SystemConfig>({
},
server: {
externalDomain: '',
sharedLinkDomain: '',
loginPageMessage: '',
publicUsers: true,
},

View file

@ -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'),

View file

@ -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'),
})

View file

@ -164,6 +164,7 @@ describe(ServerService.name, () => {
isInitialized: undefined,
isOnboarded: false,
externalDomain: '',
sharedLinkDomain: '',
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',

View file

@ -124,6 +124,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,

View file

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

View file

@ -181,6 +181,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
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 = `

View file

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

View file

@ -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
*/

View file

@ -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(),
},

View file

@ -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) => {

View file

@ -25,6 +25,14 @@
isEdited={configToEdit.server.externalDomain !== config.server.externalDomain}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_shared_link_domain_settings')}
description={$t('admin.server_shared_link_domain_settings_description')}
bind:value={configToEdit.server.sharedLinkDomain}
isEdited={configToEdit.server.sharedLinkDomain !== config.server.sharedLinkDomain}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.server_welcome_message')}