This commit is contained in:
Aram 2026-08-11 15:16:21 +06:00 committed by GitHub
commit f01e7c4f08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 106 additions and 14 deletions

View file

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

View file

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

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

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

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

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

@ -32,7 +32,9 @@ final Map<String, Map<String, Object?>> 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},

View file

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

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

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

View file

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

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