From 59bb7276361d80bae5a4a18b323a09028c85a523 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 25 Aug 2023 19:44:52 +0200 Subject: [PATCH] feat(web, server): Ability to use config file instead of admin UI (#3836) * implement method to read config file * getConfig returns config file if present * return isConfigFile for http requests * disable elements if config file is used, show message if config file is set, copy existing config to clipboard * fix allowing partial configuration files * add new env variable to docs * fix tests * minor refactoring, address review * adapt config type in frontend * remove unnecessary imports * move config file reading to system-config repo * add documentation * fix code formatting in system settings page * add validator for config file * fix formatting in docs * update generated files * throw error when trying to update config. e.g. via cli or api * switch to feature flags for isConfigFile * refactoring * refactor: config file * chore: open api * feat: always show copy/export buttons * fix: default flags * refactor: copy to clipboard --------- Co-authored-by: Jason Rasmussen --- cli/src/api/open-api/api.ts | 6 ++ docs/docs/install/config-file.md | 91 ++++++++++++++++++ docs/docs/install/environment-variables.md | 5 + docs/docs/install/post-install.mdx | 2 +- mobile/openapi/doc/ServerFeaturesDto.md | Bin 640 -> 672 bytes .../lib/model/server_features_dto.dart | Bin 4740 -> 5008 bytes .../test/server_features_dto_test.dart | Bin 1300 -> 1403 bytes server/immich-openapi-specs.json | 4 + .../src/domain/server-info/server-info.dto.ts | 1 + .../server-info/server-info.service.spec.ts | 1 + .../system-config/system-config.core.ts | 63 ++++++++++-- .../system-config/system-config.repository.ts | 1 + .../system-config.service.spec.ts | 45 +++++++++ .../repositories/system-config.repository.ts | 3 + .../system-config.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 6 ++ web/src/api/utils.ts | 14 +++ .../settings/ffmpeg/ffmpeg-settings.svelte | 13 +++ .../settings/job-settings/job-settings.svelte | 3 + .../machine-learning-settings.svelte | 12 ++- .../settings/oauth/oauth-settings.svelte | 24 ++--- .../password-login-settings.svelte | 3 + .../settings/setting-buttons-row.svelte | 5 +- .../admin-page/settings/setting-select.svelte | 2 + .../storage-template-settings.svelte | 4 + .../thumbnail/thumbnail-settings.svelte | 4 + .../components/forms/api-key-secret.svelte | 18 +--- .../create-shared-link-modal.svelte | 9 +- web/src/lib/stores/feature-flags.store.ts | 1 + web/src/lib/utils/asset-utils.ts | 2 +- .../(user)/sharing/sharedlinks/+page.svelte | 9 +- web/src/routes/+error.svelte | 22 +---- .../routes/admin/system-settings/+page.svelte | 53 ++++++++-- 33 files changed, 344 insertions(+), 83 deletions(-) create mode 100644 docs/docs/install/config-file.md diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 7ffb1f7b6f..9a47f53b7f 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -2173,6 +2173,12 @@ export interface ServerFeaturesDto { * @memberof ServerFeaturesDto */ 'clipEncode': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'configFile': boolean; /** * * @type {boolean} diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md new file mode 100644 index 0000000000..0cf131a02f --- /dev/null +++ b/docs/docs/install/config-file.md @@ -0,0 +1,91 @@ +# Config File + +A config file can be provided as an alternative to the UI configuration. + +### Step 1 - Create a new config file + +In JSON format, create a new config file (e.g. `immich.config`) and put it in a location that can be accessed by Immich. +The default configuration looks like this: + +```json +{ + "ffmpeg": { + "crf": 23, + "threads": 0, + "preset": "ultrafast", + "targetVideoCodec": "h264", + "targetAudioCodec": "aac", + "targetResolution": "720", + "maxBitrate": "0", + "twoPass": false, + "transcode": "required", + "tonemap": "hable", + "accel": "disabled" + }, + "job": { + "backgroundTask": { + "concurrency": 5 + }, + "clipEncoding": { + "concurrency": 2 + }, + "metadataExtraction": { + "concurrency": 5 + }, + "objectTagging": { + "concurrency": 2 + }, + "recognizeFaces": { + "concurrency": 2 + }, + "search": { + "concurrency": 5 + }, + "sidecar": { + "concurrency": 5 + }, + "storageTemplateMigration": { + "concurrency": 5 + }, + "thumbnailGeneration": { + "concurrency": 5 + }, + "videoConversion": { + "concurrency": 1 + } + }, + "oauth": { + "enabled": false, + "issuerUrl": "", + "clientId": "", + "clientSecret": "", + "mobileOverrideEnabled": false, + "mobileRedirectUri": "", + "scope": "openid email profile", + "storageLabelClaim": "preferred_username", + "buttonText": "Login with OAuth", + "autoRegister": true, + "autoLaunch": false + }, + "passwordLogin": { + "enabled": true + }, + "storageTemplate": { + "template": "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}" + }, + "thumbnail": { + "webpSize": 250, + "jpegSize": 1440 + } +} +``` + +:::tip +In Administration > Settings is a button to copy the current configuration to your clipboard. +So you can just grab it from there, paste it into a file and you're pretty much good to go. +::: + +### Step 2 - Specify the file location + +In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config. +For more information, refer to the [Environment Variables](https://docs.immich.app/docs/install/environment-variables) section. diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index f0ecbbb970..a344b6fd93 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 90 +--- + # Environment Variables ## Docker Compose @@ -22,6 +26,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N | `LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, microservices | | `IMMICH_MEDIA_LOCATION` | Media Location | `./upload` | server, microservices | | `PUBLIC_LOGIN_PAGE_MESSAGE` | Public Login Page Message | | web | +| `IMMICH_CONFIG_FILE` | Path to config file | | server | :::tip diff --git a/docs/docs/install/post-install.mdx b/docs/docs/install/post-install.mdx index 82fecc52f3..131c83a4ff 100644 --- a/docs/docs/install/post-install.mdx +++ b/docs/docs/install/post-install.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 100 +sidebar_position: 80 --- import RegisterAdminUser from '../partials/_register-admin.md'; diff --git a/mobile/openapi/doc/ServerFeaturesDto.md b/mobile/openapi/doc/ServerFeaturesDto.md index 9abd465140d0d441192d733ffa04f909ecf82fda..168479b9908cb3af0bdc0cb7005bed5a264b1016 100644 GIT binary patch delta 25 gcmZo*UBEiw4_9)2URq|lTV_t`SRSGk;$7GA9KQmHg_{=Gm68xsS38X3UJ=$Wz3C? zNNPn`6&Xe0vZ=NzaQ0>+HX}w^I5$;2R>4-GBqOs}4=%Pjg#7~(!m!B~xfKw+&1PI@ L*b&^xKLxu1)1gCd delta 45 zcmV+|0Mh@ECxj)ivjLOQ0pqjC0w4ji4+L=mvz!GE0kgseF9EY22;c&g2slikxunW&tJ^W&r;E3~~Sf delta 12 Tcmey(HHB+~Gt=f(Oc$8|A(8~k diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index e244c31a7f..7fec0d18eb 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -6478,6 +6478,9 @@ "clipEncode": { "type": "boolean" }, + "configFile": { + "type": "boolean" + }, "facialRecognition": { "type": "boolean" }, @@ -6501,6 +6504,7 @@ } }, "required": [ + "configFile", "clipEncode", "facialRecognition", "sidecar", diff --git a/server/src/domain/server-info/server-info.dto.ts b/server/src/domain/server-info/server-info.dto.ts index 1256f12241..b9cdd181f3 100644 --- a/server/src/domain/server-info/server-info.dto.ts +++ b/server/src/domain/server-info/server-info.dto.ts @@ -80,6 +80,7 @@ export class ServerMediaTypesResponseDto { } export class ServerFeaturesDto implements FeatureFlags { + configFile!: boolean; clipEncode!: boolean; facialRecognition!: boolean; sidecar!: boolean; diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index fefebead85..6c8d194641 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -155,6 +155,7 @@ describe(ServerInfoService.name, () => { search: true, sidecar: true, tagImage: true, + configFile: false, }); expect(configMock.load).toHaveBeenCalled(); }); diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 0b76228f5b..5fa55a7092 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -10,10 +10,13 @@ import { VideoCodec, } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common'; +import { plainToClass } from 'class-transformer'; +import { validate } from 'class-validator'; import * as _ from 'lodash'; import { Subject } from 'rxjs'; import { DeepPartial } from 'typeorm'; import { QueueName } from '../job/job.constants'; +import { SystemConfigDto } from './dto'; import { ISystemConfigRepository } from './system-config.repository'; export type SystemConfigValidator = (config: SystemConfig) => void | Promise; @@ -87,6 +90,7 @@ export enum FeatureFlag { OAUTH = 'oauth', OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch', PASSWORD_LOGIN = 'passwordLogin', + CONFIG_FILE = 'configFile', } export type FeatureFlags = Record; @@ -97,6 +101,7 @@ const singleton = new Subject(); export class SystemConfigCore { private logger = new Logger(SystemConfigCore.name); private validators: SystemConfigValidator[] = []; + private configCache: SystemConfig | null = null; public config$ = singleton; @@ -120,6 +125,8 @@ export class SystemConfigCore { throw new BadRequestException('OAuth is not enabled'); case FeatureFlag.PASSWORD_LOGIN: throw new BadRequestException('Password login is not enabled'); + case FeatureFlag.CONFIG_FILE: + throw new BadRequestException('Config file is not set'); default: throw new ForbiddenException(`Missing required feature: ${feature}`); } @@ -146,6 +153,7 @@ export class SystemConfigCore { [FeatureFlag.OAUTH]: config.oauth.enabled, [FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch, [FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled, + [FeatureFlag.CONFIG_FILE]: !!process.env.IMMICH_CONFIG_FILE, }; } @@ -157,18 +165,16 @@ export class SystemConfigCore { this.validators.push(validator); } - public async getConfig() { - const overrides = await this.repository.load(); - const config: DeepPartial = {}; - for (const { key, value } of overrides) { - // set via dot notation - _.set(config, key, value); - } - - return _.defaultsDeep(config, defaults) as SystemConfig; + public getConfig(force = false): Promise { + const configFilePath = process.env.IMMICH_CONFIG_FILE; + return configFilePath ? this.loadFromFile(configFilePath, force) : this.loadFromDatabase(); } public async updateConfig(config: SystemConfig): Promise { + if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) { + throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use'); + } + try { for (const validator of this.validators) { await validator(config); @@ -211,8 +217,45 @@ export class SystemConfigCore { } public async refreshConfig() { - const newConfig = await this.getConfig(); + const newConfig = await this.getConfig(true); this.config$.next(newConfig); } + + private async loadFromDatabase() { + const config: DeepPartial = {}; + const overrides = await this.repository.load(); + for (const { key, value } of overrides) { + // set via dot notation + _.set(config, key, value); + } + + return _.defaultsDeep(config, defaults) as SystemConfig; + } + + private async loadFromFile(filepath: string, force = false) { + if (force || !this.configCache) { + try { + const overrides = JSON.parse((await this.repository.readFile(filepath)).toString()); + const config = plainToClass(SystemConfigDto, _.defaultsDeep(overrides, defaults)); + + const errors = await validate(config, { + whitelist: true, + forbidNonWhitelisted: true, + forbidUnknownValues: true, + }); + if (errors.length > 0) { + this.logger.error('Validation error', errors); + throw new Error(`Invalid value(s) in file: ${errors}`); + } + + this.configCache = config; + } catch (error: Error | any) { + this.logger.error(`Unable to load configuration file: ${filepath} due to ${error}`, error?.stack); + throw new Error('Invalid configuration file'); + } + } + + return this.configCache; + } } diff --git a/server/src/domain/system-config/system-config.repository.ts b/server/src/domain/system-config/system-config.repository.ts index 6b04669ccc..f999792071 100644 --- a/server/src/domain/system-config/system-config.repository.ts +++ b/server/src/domain/system-config/system-config.repository.ts @@ -4,6 +4,7 @@ export const ISystemConfigRepository = 'ISystemConfigRepository'; export interface ISystemConfigRepository { load(): Promise; + readFile(filename: string): Promise; saveAll(items: SystemConfigEntity[]): Promise; deleteKeys(keys: string[]): Promise; } diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 6735b17bc9..fd450a2963 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -84,6 +84,7 @@ describe(SystemConfigService.name, () => { let jobMock: jest.Mocked; beforeEach(async () => { + delete process.env.IMMICH_CONFIG_FILE; configMock = newSystemConfigRepositoryMock(); jobMock = newJobRepositoryMock(); sut = new SystemConfigService(configMock, jobMock); @@ -126,6 +127,43 @@ describe(SystemConfigService.name, () => { await expect(sut.getConfig()).resolves.toEqual(updatedConfig); }); + + it('should load the config from a file', async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + const partialConfig = { ffmpeg: { crf: 30 }, oauth: { autoLaunch: true } }; + configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(partialConfig))); + + await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + + expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); + }); + + it('should accept an empty configuration file', async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({}))); + + await expect(sut.getConfig()).resolves.toEqual(defaults); + + expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); + }); + + const tests = [ + { should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } }, + { should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } }, + { should: 'validate enums', config: { ffmpeg: { transcode: 'unknown' } } }, + { should: 'validate top level unknown options', config: { unknownOption: true } }, + { should: 'validate nested unknown options', config: { ffmpeg: { unknownOption: true } } }, + { should: 'validate required oauth fields', config: { oauth: { enabled: true } } }, + ]; + + for (const test of tests) { + it(`should ${test.should}`, async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify(test.config))); + + await expect(sut.getConfig()).rejects.toBeInstanceOf(Error); + }); + } }); describe('getStorageTemplateOptions', () => { @@ -176,6 +214,13 @@ describe(SystemConfigService.name, () => { expect(validator).toHaveBeenCalledWith(updatedConfig); expect(configMock.saveAll).not.toHaveBeenCalled(); }); + + it('should throw an error if a config file is in use', async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; + configMock.readFile.mockResolvedValue(Buffer.from(JSON.stringify({}))); + await expect(sut.updateConfig(defaults)).rejects.toBeInstanceOf(BadRequestException); + expect(configMock.saveAll).not.toHaveBeenCalled(); + }); }); describe('refreshConfig', () => { diff --git a/server/src/infra/repositories/system-config.repository.ts b/server/src/infra/repositories/system-config.repository.ts index 0ce7c07a56..cfe0eab3d5 100644 --- a/server/src/infra/repositories/system-config.repository.ts +++ b/server/src/infra/repositories/system-config.repository.ts @@ -1,5 +1,6 @@ import { ISystemConfigRepository } from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; +import { readFile } from 'fs/promises'; import { In, Repository } from 'typeorm'; import { SystemConfigEntity } from '../entities'; @@ -13,6 +14,8 @@ export class SystemConfigRepository implements ISystemConfigRepository { return this.repository.find(); } + readFile = readFile; + saveAll(items: SystemConfigEntity[]): Promise { return this.repository.save(items); } diff --git a/server/test/repositories/system-config.repository.mock.ts b/server/test/repositories/system-config.repository.mock.ts index 258ded0a76..254f3bad23 100644 --- a/server/test/repositories/system-config.repository.mock.ts +++ b/server/test/repositories/system-config.repository.mock.ts @@ -3,6 +3,7 @@ import { ISystemConfigRepository } from '@app/domain'; export const newSystemConfigRepositoryMock = (): jest.Mocked => { return { load: jest.fn().mockResolvedValue([]), + readFile: jest.fn(), saveAll: jest.fn().mockResolvedValue([]), deleteKeys: jest.fn(), }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7ffb1f7b6f..9a47f53b7f 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -2173,6 +2173,12 @@ export interface ServerFeaturesDto { * @memberof ServerFeaturesDto */ 'clipEncode': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'configFile': boolean; /** * * @type {boolean} diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index 75f3b47863..26b6550666 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -1,9 +1,23 @@ import type { AxiosError, AxiosPromise } from 'axios'; +import { + notificationController, + NotificationType, +} from '../lib/components/shared-components/notification/notification'; +import { handleError } from '../lib/utils/handle-error'; import { api } from './api'; import type { UserResponseDto } from './open-api'; export type ApiError = AxiosError<{ message: string }>; +export const copyToClipboard = async (secret: string) => { + try { + await navigator.clipboard.writeText(secret); + notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info }); + } catch (error) { + handleError(error, 'Cannot copy to clipboard, make sure you are accessing the page through https'); + } +}; + export const oauth = { isCallback: (location: Location) => { const search = location.search; diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 3a08a171b5..e840cd04de 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -20,6 +20,7 @@ import { fade } from 'svelte/transition'; export let ffmpegConfig: SystemConfigFFmpegDto; // this is the config that is being edited + export let disabled = false; let savedConfig: SystemConfigFFmpegDto; let defaultConfig: SystemConfigFFmpegDto; @@ -90,6 +91,7 @@
diff --git a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte index 5ec9a34d54..b795db7d30 100644 --- a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte +++ b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte @@ -11,6 +11,7 @@ import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; export let jobConfig: SystemConfigJobDto; // this is the config that is being edited + export let disabled = false; let savedConfig: SystemConfigJobDto; let defaultConfig: SystemConfigJobDto; @@ -78,6 +79,7 @@
diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 4b10e8535f..f8ac971cf2 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -11,6 +11,8 @@ import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; import SettingSwitch from '../setting-switch.svelte'; + export let disabled = false; + let config: SystemConfigDto; let defaultConfig: SystemConfigDto; @@ -56,6 +58,7 @@ @@ -67,7 +70,7 @@ desc="URL of machine learning server" bind:value={config.machineLearning.url} required={true} - disabled={!config.machineLearning.enabled} + disabled={disabled || !config.machineLearning.enabled} isEdited={!(config.machineLearning.url === config.machineLearning.url)} /> @@ -75,20 +78,20 @@ title="SMART SEARCH" subtitle="Extract CLIP embeddings for smart search" bind:checked={config.machineLearning.clipEncodeEnabled} - disabled={!config.machineLearning.enabled} + disabled={disabled || !config.machineLearning.enabled} /> @@ -97,6 +100,7 @@ on:save={saveSetting} on:reset-to-default={resetToDefault} showResetToDefault={!isEqual(config, defaultConfig)} + {disabled} /> diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index 9e5ad58a96..0c6c4a5629 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -13,6 +13,7 @@ import SettingSwitch from '../setting-switch.svelte'; export let oauthConfig: SystemConfigOAuthDto; + export let disabled = false; let savedConfig: SystemConfigOAuthDto; let defaultConfig: SystemConfigOAuthDto; @@ -117,14 +118,14 @@ >.

- +
@@ -133,7 +134,7 @@ label="CLIENT ID" bind:value={oauthConfig.clientId} required={true} - disabled={!oauthConfig.enabled} + disabled={disabled || !oauthConfig.enabled} isEdited={!(oauthConfig.clientId == savedConfig.clientId)} /> @@ -142,7 +143,7 @@ label="CLIENT SECRET" bind:value={oauthConfig.clientSecret} required={true} - disabled={!oauthConfig.enabled} + disabled={disabled || !oauthConfig.enabled} isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)} /> @@ -151,7 +152,7 @@ label="SCOPE" bind:value={oauthConfig.scope} required={true} - disabled={!oauthConfig.enabled} + disabled={disabled || !oauthConfig.enabled} isEdited={!(oauthConfig.scope == savedConfig.scope)} /> @@ -161,7 +162,7 @@ desc="Automatically set the user's storage label to the value of this claim." bind:value={oauthConfig.storageLabelClaim} required={true} - disabled={!oauthConfig.storageLabelClaim} + disabled={disabled || !oauthConfig.storageLabelClaim} isEdited={!(oauthConfig.storageLabelClaim == savedConfig.storageLabelClaim)} /> @@ -170,7 +171,7 @@ label="BUTTON TEXT" bind:value={oauthConfig.buttonText} required={false} - disabled={!oauthConfig.enabled} + disabled={disabled || !oauthConfig.enabled} isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)} /> @@ -178,20 +179,20 @@ title="AUTO REGISTER" subtitle="Automatically register new users after signing in with OAuth" bind:checked={oauthConfig.autoRegister} - disabled={!oauthConfig.enabled} + disabled={disabled || !oauthConfig.enabled} /> handleToggleOverride()} bind:checked={oauthConfig.mobileOverrideEnabled} /> @@ -202,7 +203,7 @@ label="MOBILE REDIRECT URI" bind:value={oauthConfig.mobileRedirectUri} required={true} - disabled={!oauthConfig.enabled} + disabled={disabled || !oauthConfig.enabled} isEdited={!(oauthConfig.mobileRedirectUri == savedConfig.mobileRedirectUri)} /> {/if} @@ -212,6 +213,7 @@ on:save={saveSetting} on:reset-to-default={resetToDefault} showResetToDefault={!isEqual(savedConfig, defaultConfig)} + {disabled} /> diff --git a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte b/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte index fc9c938c5e..2c11512f54 100644 --- a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte +++ b/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte @@ -12,6 +12,7 @@ import SettingSwitch from '../setting-switch.svelte'; export let passwordLoginConfig: SystemConfigPasswordLoginDto; // this is the config that is being edited + export let disabled = false; let savedConfig: SystemConfigPasswordLoginDto; let defaultConfig: SystemConfigPasswordLoginDto; @@ -100,6 +101,7 @@
@@ -109,6 +111,7 @@ on:save={saveSetting} on:reset-to-default={resetToDefault} showResetToDefault={!isEqual(savedConfig, defaultConfig)} + {disabled} />
diff --git a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte index 679327e6d6..3931d41eb2 100644 --- a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte +++ b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte @@ -5,6 +5,7 @@ const dispatch = createEventDispatcher(); export let showResetToDefault = true; + export let disabled = false;
@@ -20,7 +21,7 @@
- - + +
diff --git a/web/src/lib/components/admin-page/settings/setting-select.svelte b/web/src/lib/components/admin-page/settings/setting-select.svelte index 4acc1c2ade..cafa053932 100644 --- a/web/src/lib/components/admin-page/settings/setting-select.svelte +++ b/web/src/lib/components/admin-page/settings/setting-select.svelte @@ -9,6 +9,7 @@ export let name = ''; export let isEdited = false; export let number = false; + export let disabled = false; const handleChange = (e: Event) => { value = (e.target as HTMLInputElement).value; @@ -40,6 +41,7 @@ diff --git a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte index 5370611d94..80f8f37860 100644 --- a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte +++ b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte @@ -10,6 +10,7 @@ } from '$lib/components/shared-components/notification/notification'; export let thumbnailConfig: SystemConfigThumbnailDto; // this is the config that is being edited + export let disabled = false; let savedConfig: SystemConfigThumbnailDto; let defaultConfig: SystemConfigThumbnailDto; @@ -91,6 +92,7 @@ ]} name="resolution" isEdited={!(thumbnailConfig.webpSize === savedConfig.webpSize)} + {disabled} /> @@ -113,6 +116,7 @@ on:save={saveSetting} on:reset-to-default={resetToDefault} showResetToDefault={!isEqual(savedConfig, defaultConfig)} + {disabled} /> diff --git a/web/src/lib/components/forms/api-key-secret.svelte b/web/src/lib/components/forms/api-key-secret.svelte index ed8bd72985..f9e2ae9080 100644 --- a/web/src/lib/components/forms/api-key-secret.svelte +++ b/web/src/lib/components/forms/api-key-secret.svelte @@ -1,10 +1,9 @@ @@ -51,7 +39,7 @@
{#if canCopyImagesToClipboard} - + {/if}
diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index ebd503f5c9..58fc610b0e 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -5,7 +5,7 @@ import SettingSwitch from '$lib/components/admin-page/settings/setting-switch.svelte'; import Button from '$lib/components/elements/buttons/button.svelte'; import { handleError } from '$lib/utils/handle-error'; - import { api, SharedLinkResponseDto, SharedLinkType } from '@api'; + import { api, copyToClipboard, SharedLinkResponseDto, SharedLinkType } from '@api'; import { createEventDispatcher, onMount } from 'svelte'; import Link from 'svelte-material-icons/Link.svelte'; import BaseModal from '../base-modal.svelte'; @@ -80,12 +80,7 @@ return; } - try { - await navigator.clipboard.writeText(sharedLink); - notificationController.show({ message: 'Copied to clipboard!', type: NotificationType.Info }); - } catch (e) { - handleError(e, 'Cannot copy to clipboard, make sure you are accessing the page through https'); - } + await copyToClipboard(sharedLink); }; const getExpirationTimeInMillisecond = () => { diff --git a/web/src/lib/stores/feature-flags.store.ts b/web/src/lib/stores/feature-flags.store.ts index 57a8f33cd5..b14bfa7e01 100644 --- a/web/src/lib/stores/feature-flags.store.ts +++ b/web/src/lib/stores/feature-flags.store.ts @@ -12,6 +12,7 @@ export const featureFlags = writable({ oauth: true, oauthAutoLaunch: true, passwordLogin: true, + configFile: false, }); export const loadFeatureFlags = async () => { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 0c19a3c4e4..704be329a9 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -20,7 +20,7 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: Array) return results; }); -const downloadBlob = (data: Blob, filename: string) => { +export const downloadBlob = (data: Blob, filename: string) => { const url = URL.createObjectURL(data); const anchor = document.createElement('a'); diff --git a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte index cb12b83deb..8db99f2e51 100644 --- a/web/src/routes/(user)/sharing/sharedlinks/+page.svelte +++ b/web/src/routes/(user)/sharing/sharedlinks/+page.svelte @@ -1,7 +1,7 @@ diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index aa0391e3d6..7a19151536 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,15 +1,11 @@ diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 5da12e1862..21a176f0f8 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -8,8 +8,15 @@ import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte'; import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte'; import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte'; + import Button from '$lib/components/elements/buttons/button.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; - import { api } from '@api'; + import { downloadManager } from '$lib/stores/download'; + import { featureFlags } from '$lib/stores/feature-flags.store'; + import { downloadBlob } from '$lib/utils/asset-utils'; + import { SystemConfigDto, api, copyToClipboard } from '@api'; + import Alert from 'svelte-material-icons/Alert.svelte'; + import ContentCopy from 'svelte-material-icons/ContentCopy.svelte'; + import Download from 'svelte-material-icons/Download.svelte'; import type { PageData } from './$types'; export let data: PageData; @@ -18,21 +25,47 @@ const { data } = await api.systemConfigApi.getConfig(); return data; }; + + const downloadConfig = (configs: SystemConfigDto) => { + const blob = new Blob([JSON.stringify(configs, null, 2)], { type: 'application/json' }); + const downloadKey = 'immich-config.json'; + downloadManager.add(downloadKey, blob.size); + downloadManager.update(downloadKey, blob.size); + downloadBlob(blob, downloadKey); + setTimeout(() => downloadManager.clear(downloadKey), 5_000); + }; +{#if $featureFlags.configFile} +
+ +

Config is currently set by a config file

+
+{/if} +
{#await getConfig()} {:then configs} +
+ + +
- + - + - + - + - + - + - + {/await}