feat: maintenance mode (#23431)

* feat: add a `maintenance.enabled` config flag

* feat: implement graceful restart
feat: restart when maintenance config is toggled

* feat: boot a stripped down maintenance api if enabled

* feat: cli command to toggle maintenance mode

* chore: fallback IMMICH_SERVER_URL environment variable in process

* chore: add additional routes to maintenance controller

* fix: don't wait for nest application to close to finish request response

* chore: add a failsafe on restart to prevent other exit codes from preventing restart

* feat: redirect into/from maintenance page

* refactor: use system metadata for maintenance status

* refactor: wait on WebSocket connection to refresh

* feat: broadcast websocket event on server restart
refactor: listen to WS instead of polling

* refactor: bubble up maintenance information instead of hijacking in fetch function
feat: show modal when server is restarting

* chore: increase timeout for ungraceful restart

* refactor: deduplicate code between api/maintenance workers

* fix: skip config check if database is not initialised

* fix: add `maintenanceMode` field to system config test

* refactor: move maintenance resolution code to static method in service

* chore: clean up linter issues

* chore: generate dart openapi

* refactor: use try{} block for maintenance mode check

* fix: logic error in server redirect

* chore: include `maintenanceMode` key in e2e test

* chore: add i18n entries for maintenance screens

* chore: remove negated condition from hook

* fix: should set default value not override in service

* fix: minor error in page

* feat: initial draft of maintenance module, repo., worker controller, worker service

* refactor: move broadcast code into notification service

* chore: connect websocket on client if in maintenance

* chore: set maintenance module app name

* refactor: rename repository to include worker
chore: configure websocket adapter

* feat: reimplement maintenance mode exit with new module

* refactor: add a constant enum for ExitCode

* refactor: remove redundant route for maintenance

* refactor: only spin up kysely on boot (rather than a Nest app)

* refactor(web): move redirect logic into +layout file where modal is setup

* feat: add Maintenance permission

* refactor: merge common code between api/maintenance

* fix: propagate changes from the CLI to servers

* feat: maintenance authentication guard

* refactor: unify maintenance code into repository
feat: add a step to generate maintenance mode token

* feat: jwt auth for maintenance

* refactor: switch from nest jwt to just jsonwebtokens

* feat: log into maintenance mode from CLI command

* refactor: use `secret` instead of `token` in jwt terminology
chore: log maintenance mode login URL on boot
chore: don't make CLI actions reload if already in target state

* docs: initial draft for maintenance mode page

* refactor: always validate the maintenance auth on the server

* feat: add a link to maintenance mode documentation

* feat: redirect users back to the last page they were on when exiting maintenance

* refactor: provide closeFn in both maintenance repos.

* refactor: ensure the user is also redirected by the server

* chore: swap jsonwebtoken for jose

* refactor: introduce AppRestartEvent w/o secret passing

* refactor: use navigation goto

* refactor: use `continue` instead of `next`

* chore: lint fixes for server

* chore: lint fixes for web

* test: add mock for maintenance repository

* test: add base service dependency to maintenance

* chore: remove @types/jsonwebtoken

* refactor: close database connection after startup check

* refactor: use `request#auth` key

* refactor: use service instead of repository
chore: read token from cookie if possible
chore: rename client event to AppRestartV1

* refactor: more concise redirect logic on web

* refactor: move redirect check into utils
refactor: update translation strings to be more sensible

* refactor: always validate login (i.e. check cookie)

* refactor: lint, open-api, remove old dto

* refactor: encode at point of usage

* refactor: remove business logic from repositories

* chore: fix server/web lints

* refactor: remove repository mock

* chore: fix formatting

* test: write service mocks for maintenance mode

* test: write cli service tests

* fix: catch errors when closing app

* fix: always report no maintenance when usual API is available

* test: api e2e maintenance spec

* chore: add response builder

* chore: add helper to set maint. auth cookie

* feat: add SSR to maintenance API

* test(e2e): write web spec for maintenance

* chore: clean up lint issues

* chore: format files

* feat: perform 302 redirect at server level during maintenance

* fix: keep trying to stop immich until it succeeds (CLI issue)

* chore: lint/format

* refactor: annotate references to other services in worker service

* chore: lint

* refactor: remove unnecessary await

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* refactor: move static methods into util

* refactor: assert secret exists in maintenance worker

* refactor: remove assertion which isn't necessary anymore

* refactor: remove assertion

* refactor: remove outer try {} catch block from loadMaintenanceAuth

* refactor: undo earlier change to vite.config.ts

* chore: update tests due to refactors

* revert: vite.config.ts

* test: expect string jwt

* chore: move blanket exceptions into controllers

* test: update tests according with last change

* refactor: use respondWithCookie
refactor: merge start/end into one route
refactor: rename MaintenanceRepository to AppRepository
chore: use new ApiTag/Endpoint
refactor: apply other requested changes

* chore: regenerate openapi

* chore: lint/format

* chore: remove secureOnly for maint. cookie

* refactor: move maintenance worker code into src/maintenance\nfix: various test fixes

* refactor: use `action` property for setting maint. mode

* refactor: remove Websocket#restartApp in favour of individual methods

* chore: incomplete commit

* chore: remove stray log

* fix: call exitApp from maintenance worker on exit

* fix: add app repository mock

* fix: ensure maintenance cookies are secure

* fix: run playwright tests over secure context (localhost)

* test: update other references to 127.0.0.1

* refactor: use serverSideEmitWithAck

* chore: correct the logic in tryTerminate

* test: juggle cookies ourselves

* chore: fix lint error for e2e spec

* chore: format e2e test

* fix: set cookie secure/non-secure depending on context

* chore: format files

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
pull/13851/head
Paul Makles 2025-11-17 17:15:44 +07:00 committed by GitHub
parent ce82e27f4b
commit 15e00f82f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 2590 additions and 134 deletions

@ -0,0 +1,18 @@
# Maintenance Mode
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
You can enter maintenance mode by either:
- Selecting "enable maintenance mode" in system settings in administration.
- Running the enable maintenance mode [administration command](./server-commands.md).
## Logging in during maintenance
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
If you find that you've been logged out, you can:
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.

@ -2,17 +2,19 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| Command | Description |
| -------------------------- | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `disable-maintenance-mode` | Disable maintenance mode |
| `enable-maintenance-mode` | Enable maintenance mode |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
## How to run a command
@ -47,6 +49,23 @@ immich-admin enable-password-login
Password login has been enabled.
```
Disable Maintenance Mode
```
immich-admin disable-maintenace-mode
Maintenance mode has been disabled.
```
Enable Maintenance Mode
```
immich-admin enable-maintenance-mode
Maintenance mode has been enabled.
Log in using the following URL:
https://my.immich.app/maintenance?token=<token>
```
Enable OAuth login
```

@ -0,0 +1,172 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/admin/maintenance', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
// => outside of maintenance mode
describe('GET ~/server/config', async () => {
it('should indicate we are out of maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeFalsy();
});
});
describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
});
});
// => enter maintenance mode
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
action: 'end',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should be a no-op if try to exit maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(201);
});
it('should enter maintenance mode', async () => {
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'start',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
expect(cookie).toEqual(
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeTruthy();
});
});
// => in maintenance mode
describe.sequential('in maintenance mode', () => {
describe('GET ~/server/config', async () => {
it('should indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeTruthy();
});
});
describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
});
it('should succeed with cookie', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
it('should succeed with token', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance/login')
.send({
token: cookie!.split('=')[1].trim(),
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
});
describe('POST /', async () => {
it('should be a no-op if try to enter maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('cookie', cookie!)
.send({ action: 'start' });
expect(status).toBe(201);
});
});
});
// => exit maintenance mode
describe.sequential('POST /', () => {
it('should exit maintenance mode', async () => {
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeFalsy();
});
});
});

@ -136,6 +136,7 @@ describe('/server', () => {
externalDomain: '',
publicUsers: true,
isOnboarded: false,
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});

@ -7,6 +7,12 @@ export const errorDto = {
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,

@ -6,6 +6,7 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
MaintenanceAction,
MetadataSearchDto,
Permission,
PersonCreateDto,
@ -36,6 +37,7 @@ import {
scanLibrary,
searchAssets,
setBaseUrl,
setMaintenanceMode,
signUpAdmin,
tagAssets,
updateAdminOnboarding,
@ -514,6 +516,42 @@ export const utils = {
},
]),
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
await context.addCookies([
{
name: 'immich_maintenance_token',
value: token,
domain,
path: '/',
expires: 2_058_028_213,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
]),
enterMaintenance: async (accessToken: string) => {
let setCookie: string[] | undefined;
await setMaintenanceMode(
{
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
},
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
}),
},
);
return setCookie;
},
resetTempFolder: () => {
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
mkdirSync(`${testAssetDir}/temp`, { recursive: true });

@ -0,0 +1,52 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Maintenance', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/system-settings?isOpen=maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
await page.waitForURL(`/maintenance?${new URLSearchParams({ continue: '/admin/system-settings' })}`);
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/admin/system-settings');
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie
?.map((cookie) => cookie.split(';')[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy();
await expect(async () => {
await page.goto('/');
await page.waitForURL('/maintenance?**', {
timeout: 1e3,
});
}).toPass({ timeout: 1e4 });
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('/auth/login');
});
});

@ -174,6 +174,10 @@
"machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_settings": "Maintenance",
"maintenance_settings_description": "Put Immich into maintenance mode.",
"maintenance_start": "Start maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.",
"manage_concurrency": "Manage Concurrency",
"manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style",
@ -1318,6 +1322,11 @@
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode",
"maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_title": "Temporarily Unavailable",
"make": "Make",
"manage_geolocation": "Manage location",
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
@ -1830,6 +1839,8 @@
"server_offline": "Server Offline",
"server_online": "Server Online",
"server_privacy": "Server Privacy",
"server_restarting_description": "This page will refresh momentarily.",
"server_restarting_title": "Server is restarting",
"server_stats": "Server Stats",
"server_update_available": "Server update is available",
"server_version": "Server Version",

@ -159,6 +159,8 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory
@ -404,6 +406,9 @@ Class | Method | HTTP request | Description
- [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md)
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
- [MaintenanceAction](doc//MaintenanceAction.md)
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
@ -496,6 +501,7 @@ Class | Method | HTTP request | Description
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SessionUnlockDto](doc//SessionUnlockDto.md)
- [SessionUpdateDto](doc//SessionUpdateDto.md)
- [SetMaintenanceModeDto](doc//SetMaintenanceModeDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)

@ -42,6 +42,7 @@ part 'api/duplicates_api.dart';
part 'api/faces_api.dart';
part 'api/jobs_api.dart';
part 'api/libraries_api.dart';
part 'api/maintenance_admin_api.dart';
part 'api/map_api.dart';
part 'api/memories_api.dart';
part 'api/notifications_api.dart';
@ -163,6 +164,9 @@ part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart';
part 'model/machine_learning_availability_checks_dto.dart';
part 'model/maintenance_action.dart';
part 'model/maintenance_auth_dto.dart';
part 'model/maintenance_login_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart';
@ -255,6 +259,7 @@ part 'model/session_create_response_dto.dart';
part 'model/session_response_dto.dart';
part 'model/session_unlock_dto.dart';
part 'model/session_update_dto.dart';
part 'model/set_maintenance_mode_dto.dart';
part 'model/shared_link_create_dto.dart';
part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_dto.dart';

@ -0,0 +1,122 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceAdminApi {
MaintenanceAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
Future<Response> maintenanceLoginWithHttpInfo(MaintenanceLoginDto maintenanceLoginDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/login';
// ignore: prefer_final_locals
Object? postBody = maintenanceLoginDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.
///
/// Parameters:
///
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
Future<MaintenanceAuthDto?> maintenanceLogin(MaintenanceLoginDto maintenanceLoginDto,) async {
final response = await maintenanceLoginWithHttpInfo(maintenanceLoginDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceAuthDto',) as MaintenanceAuthDto;
}
return null;
}
/// Set maintenance mode
///
/// Put Immich into or take it out of maintenance mode
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
Future<Response> setMaintenanceModeWithHttpInfo(SetMaintenanceModeDto setMaintenanceModeDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance';
// ignore: prefer_final_locals
Object? postBody = setMaintenanceModeDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Set maintenance mode
///
/// Put Immich into or take it out of maintenance mode
///
/// Parameters:
///
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
Future<void> setMaintenanceMode(SetMaintenanceModeDto setMaintenanceModeDto,) async {
final response = await setMaintenanceModeWithHttpInfo(setMaintenanceModeDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

@ -378,6 +378,12 @@ class ApiClient {
return LogoutResponseDto.fromJson(value);
case 'MachineLearningAvailabilityChecksDto':
return MachineLearningAvailabilityChecksDto.fromJson(value);
case 'MaintenanceAction':
return MaintenanceActionTypeTransformer().decode(value);
case 'MaintenanceAuthDto':
return MaintenanceAuthDto.fromJson(value);
case 'MaintenanceLoginDto':
return MaintenanceLoginDto.fromJson(value);
case 'ManualJobName':
return ManualJobNameTypeTransformer().decode(value);
case 'MapMarkerResponseDto':
@ -562,6 +568,8 @@ class ApiClient {
return SessionUnlockDto.fromJson(value);
case 'SessionUpdateDto':
return SessionUpdateDto.fromJson(value);
case 'SetMaintenanceModeDto':
return SetMaintenanceModeDto.fromJson(value);
case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto':

@ -97,6 +97,9 @@ String parameterToString(dynamic value) {
if (value is LogLevel) {
return LogLevelTypeTransformer().encode(value).toString();
}
if (value is MaintenanceAction) {
return MaintenanceActionTypeTransformer().encode(value).toString();
}
if (value is ManualJobName) {
return ManualJobNameTypeTransformer().encode(value).toString();
}

@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceAction {
/// Instantiate a new enum with the provided [value].
const MaintenanceAction._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const start = MaintenanceAction._(r'start');
static const end = MaintenanceAction._(r'end');
/// List of all possible values in this [enum][MaintenanceAction].
static const values = <MaintenanceAction>[
start,
end,
];
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
static List<MaintenanceAction> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceAction>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceAction.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MaintenanceAction] to String,
/// and [decode] dynamic data back to [MaintenanceAction].
class MaintenanceActionTypeTransformer {
factory MaintenanceActionTypeTransformer() => _instance ??= const MaintenanceActionTypeTransformer._();
const MaintenanceActionTypeTransformer._();
String encode(MaintenanceAction data) => data.value;
/// Decodes a [dynamic value][data] to a MaintenanceAction.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
MaintenanceAction? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'start': return MaintenanceAction.start;
case r'end': return MaintenanceAction.end;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MaintenanceActionTypeTransformer] instance.
static MaintenanceActionTypeTransformer? _instance;
}

@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceAuthDto {
/// Returns a new [MaintenanceAuthDto] instance.
MaintenanceAuthDto({
required this.username,
});
String username;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceAuthDto &&
other.username == username;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(username.hashCode);
@override
String toString() => 'MaintenanceAuthDto[username=$username]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'username'] = this.username;
return json;
}
/// Returns a new [MaintenanceAuthDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceAuthDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceAuthDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceAuthDto(
username: mapValueOfType<String>(json, r'username')!,
);
}
return null;
}
static List<MaintenanceAuthDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceAuthDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceAuthDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceAuthDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceAuthDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceAuthDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceAuthDto-objects as value to a dart map
static Map<String, List<MaintenanceAuthDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceAuthDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceAuthDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'username',
};
}

@ -0,0 +1,108 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MaintenanceLoginDto {
/// Returns a new [MaintenanceLoginDto] instance.
MaintenanceLoginDto({
this.token,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? token;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceLoginDto &&
other.token == token;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(token == null ? 0 : token!.hashCode);
@override
String toString() => 'MaintenanceLoginDto[token=$token]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.token != null) {
json[r'token'] = this.token;
} else {
// json[r'token'] = null;
}
return json;
}
/// Returns a new [MaintenanceLoginDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceLoginDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceLoginDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceLoginDto(
token: mapValueOfType<String>(json, r'token'),
);
}
return null;
}
static List<MaintenanceLoginDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceLoginDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceLoginDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceLoginDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceLoginDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceLoginDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceLoginDto-objects as value to a dart map
static Map<String, List<MaintenanceLoginDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceLoginDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceLoginDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

@ -73,6 +73,7 @@ class Permission {
static const libraryPeriodStatistics = Permission._(r'library.statistics');
static const timelinePeriodRead = Permission._(r'timeline.read');
static const timelinePeriodDownload = Permission._(r'timeline.download');
static const maintenance = Permission._(r'maintenance');
static const memoryPeriodCreate = Permission._(r'memory.create');
static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update');
@ -214,6 +215,7 @@ class Permission {
libraryPeriodStatistics,
timelinePeriodRead,
timelinePeriodDownload,
maintenance,
memoryPeriodCreate,
memoryPeriodRead,
memoryPeriodUpdate,
@ -390,6 +392,7 @@ class PermissionTypeTransformer {
case r'library.statistics': return Permission.libraryPeriodStatistics;
case r'timeline.read': return Permission.timelinePeriodRead;
case r'timeline.download': return Permission.timelinePeriodDownload;
case r'maintenance': return Permission.maintenance;
case r'memory.create': return Permission.memoryPeriodCreate;
case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate;

@ -17,6 +17,7 @@ class ServerConfigDto {
required this.isInitialized,
required this.isOnboarded,
required this.loginPageMessage,
required this.maintenanceMode,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
required this.oauthButtonText,
@ -33,6 +34,8 @@ class ServerConfigDto {
String loginPageMessage;
bool maintenanceMode;
String mapDarkStyleUrl;
String mapLightStyleUrl;
@ -51,6 +54,7 @@ class ServerConfigDto {
other.isInitialized == isInitialized &&
other.isOnboarded == isOnboarded &&
other.loginPageMessage == loginPageMessage &&
other.maintenanceMode == maintenanceMode &&
other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl &&
other.oauthButtonText == oauthButtonText &&
@ -65,6 +69,7 @@ class ServerConfigDto {
(isInitialized.hashCode) +
(isOnboarded.hashCode) +
(loginPageMessage.hashCode) +
(maintenanceMode.hashCode) +
(mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) +
(oauthButtonText.hashCode) +
@ -73,7 +78,7 @@ class ServerConfigDto {
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, 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, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -81,6 +86,7 @@ class ServerConfigDto {
json[r'isInitialized'] = this.isInitialized;
json[r'isOnboarded'] = this.isOnboarded;
json[r'loginPageMessage'] = this.loginPageMessage;
json[r'maintenanceMode'] = this.maintenanceMode;
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'oauthButtonText'] = this.oauthButtonText;
@ -103,6 +109,7 @@ class ServerConfigDto {
isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
@ -160,6 +167,7 @@ class ServerConfigDto {
'isInitialized',
'isOnboarded',
'loginPageMessage',
'maintenanceMode',
'mapDarkStyleUrl',
'mapLightStyleUrl',
'oauthButtonText',

@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SetMaintenanceModeDto {
/// Returns a new [SetMaintenanceModeDto] instance.
SetMaintenanceModeDto({
required this.action,
});
MaintenanceAction action;
@override
bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto &&
other.action == action;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode);
@override
String toString() => 'SetMaintenanceModeDto[action=$action]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
return json;
}
/// Returns a new [SetMaintenanceModeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SetMaintenanceModeDto? fromJson(dynamic value) {
upgradeDto(value, "SetMaintenanceModeDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SetMaintenanceModeDto(
action: MaintenanceAction.fromJson(json[r'action'])!,
);
}
return null;
}
static List<SetMaintenanceModeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SetMaintenanceModeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SetMaintenanceModeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SetMaintenanceModeDto> mapFromJson(dynamic json) {
final map = <String, SetMaintenanceModeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SetMaintenanceModeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SetMaintenanceModeDto-objects as value to a dart map
static Map<String, List<SetMaintenanceModeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SetMaintenanceModeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SetMaintenanceModeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
};
}

@ -322,6 +322,100 @@
"x-immich-state": "Stable"
}
},
"/admin/maintenance": {
"post": {
"description": "Put Immich into or take it out of maintenance mode",
"operationId": "setMaintenanceMode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetMaintenanceModeDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Set maintenance mode",
"tags": [
"Maintenance (admin)"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "maintenance",
"x-immich-state": "Alpha"
}
},
"/admin/maintenance/login": {
"post": {
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
"operationId": "maintenanceLogin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceLoginDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceAuthDto"
}
}
},
"description": ""
}
},
"summary": "Log into maintenance mode",
"tags": [
"Maintenance (admin)"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/admin/notifications": {
"post": {
"description": "Create a new notification for a specific user.",
@ -13917,6 +14011,10 @@
"name": "Libraries",
"description": "An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries."
},
{
"name": "Maintenance (admin)",
"description": "Maintenance mode allows you to put Immich in a read-only state to perform various operations."
},
{
"name": "Map",
"description": "Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data."
@ -16425,6 +16523,32 @@
],
"type": "object"
},
"MaintenanceAction": {
"enum": [
"start",
"end"
],
"type": "string"
},
"MaintenanceAuthDto": {
"properties": {
"username": {
"type": "string"
}
},
"required": [
"username"
],
"type": "object"
},
"MaintenanceLoginDto": {
"properties": {
"token": {
"type": "string"
}
},
"type": "object"
},
"ManualJobName": {
"enum": [
"person-cleanup",
@ -17380,6 +17504,7 @@
"library.statistics",
"timeline.read",
"timeline.download",
"maintenance",
"memory.create",
"memory.read",
"memory.update",
@ -18587,6 +18712,9 @@
"loginPageMessage": {
"type": "string"
},
"maintenanceMode": {
"type": "boolean"
},
"mapDarkStyleUrl": {
"type": "string"
},
@ -18611,6 +18739,7 @@
"isInitialized",
"isOnboarded",
"loginPageMessage",
"maintenanceMode",
"mapDarkStyleUrl",
"mapLightStyleUrl",
"oauthButtonText",
@ -18996,6 +19125,21 @@
},
"type": "object"
},
"SetMaintenanceModeDto": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/MaintenanceAction"
}
]
}
},
"required": [
"action"
],
"type": "object"
},
"SharedLinkCreateDto": {
"properties": {
"albumId": {

@ -40,6 +40,15 @@ export type ActivityStatisticsResponseDto = {
comments: number;
likes: number;
};
export type SetMaintenanceModeDto = {
action: MaintenanceAction;
};
export type MaintenanceLoginDto = {
token?: string;
};
export type MaintenanceAuthDto = {
username: string;
};
export type NotificationCreateDto = {
data?: object;
description?: string | null;
@ -1183,6 +1192,7 @@ export type ServerConfigDto = {
isInitialized: boolean;
isOnboarded: boolean;
loginPageMessage: string;
maintenanceMode: boolean;
mapDarkStyleUrl: string;
mapLightStyleUrl: string;
oauthButtonText: string;
@ -1822,6 +1832,33 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
/**
* Set maintenance mode
*/
export function setMaintenanceMode({ setMaintenanceModeDto }: {
setMaintenanceModeDto: SetMaintenanceModeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/admin/maintenance", oazapfts.json({
...opts,
method: "POST",
body: setMaintenanceModeDto
})));
}
/**
* Log into maintenance mode
*/
export function maintenanceLogin({ maintenanceLoginDto }: {
maintenanceLoginDto: MaintenanceLoginDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: MaintenanceAuthDto;
}>("/admin/maintenance/login", oazapfts.json({
...opts,
method: "POST",
body: maintenanceLoginDto
})));
}
/**
* Create a notification
*/
@ -5014,6 +5051,10 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum MaintenanceAction {
Start = "start",
End = "end"
}
export enum NotificationLevel {
Success = "success",
Error = "error",
@ -5121,6 +5162,7 @@ export enum Permission {
LibraryStatistics = "library.statistics",
TimelineRead = "timeline.read",
TimelineDownload = "timeline.download",
Maintenance = "maintenance",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",

@ -445,6 +445,9 @@ importers:
ioredis:
specifier: ^5.8.2
version: 5.8.2
jose:
specifier: ^5.10.0
version: 5.10.0
js-yaml:
specifier: ^4.1.0
version: 4.1.0

@ -78,6 +78,7 @@
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.2",

@ -0,0 +1,87 @@
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service';
import { useSwagger } from 'src/utils/misc';
export function configureTelemetry() {
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.apiPort);
}
}
export async function configureExpress(
app: NestExpressApplication,
{
permitSwaggerWrite = true,
ssr,
}: {
/**
* Whether to allow swagger module to write to the specs.json
* This is not desirable when the API is not available
* @default true
*/
permitSwaggerWrite?: boolean;
/**
* Service to use for server-side rendering
*/
ssr: typeof ApiService | typeof MaintenanceWorkerService;
},
) {
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
const logger = await app.resolve(LoggingRepository);
logger.setContext('Bootstrap');
app.useLogger(logger);
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
}
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,
extensions: [],
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
}
app.use(app.get(ssr).ssr(excludePaths));
app.use(compression());
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}

@ -9,15 +9,21 @@ import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { services } from 'src/services';
@ -28,27 +34,27 @@ import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
export const middleware = [
FileUploadInterceptor,
const commonMiddleware = [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_GUARD, useClass: AuthGuard },
];
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const imports = [
BullModule.forRoot(bull.config),
BullModule.registerQueue(...bull.queues),
const commonImports = [
ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config)),
OpenTelemetryModule.forRoot(otel),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
export class BaseModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
@ -85,20 +91,44 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
}
@Module({
imports: [...imports, ScheduleModule.forRoot()],
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...commonImports],
controllers: [MaintenanceWorkerController],
providers: [
ConfigRepository,
LoggingRepository,
SystemMetadataRepository,
AppRepository,
MaintenanceWebsocketRepository,
MaintenanceWorkerService,
...commonMiddleware,
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard },
{ provide: IWorker, useValue: ImmichWorker.Maintenance },
],
})
export class MaintenanceModule {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
) {
logger.setAppName(this.worker);
}
}
@Module({
imports: [...bullImports, ...commonImports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],
})
export class MicroservicesModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...bullImports, ...commonImports],
providers: [...common, ...commandsAndQuestions, SchedulerRegistry],
})
export class ImmichAdminModule implements OnModuleDestroy {

@ -1,5 +1,6 @@
import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin';
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableMaintenanceModeCommand, EnableMaintenanceModeCommand } from 'src/commands/maintenance-mode';
import {
ChangeMediaLocationCommand,
PromptConfirmMoveQuestions,
@ -16,6 +17,8 @@ export const commandsAndQuestions = [
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableMaintenanceModeCommand,
DisableMaintenanceModeCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,

@ -0,0 +1,37 @@
import { Command, CommandRunner } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'enable-maintenance-mode',
description: 'Enable maintenance mode or regenerate the maintenance token',
})
export class EnableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { authUrl, alreadyEnabled } = await this.service.enableMaintenanceMode();
console.info(alreadyEnabled ? 'The server is already in maintenance mode!' : 'Maintenance mode has been enabled.');
console.info(`\nLog in using the following URL:\n${authUrl}`);
}
}
@Command({
name: 'disable-maintenance-mode',
description: 'Disable maintenance mode',
})
export class DisableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { alreadyDisabled } = await this.service.disableMaintenanceMode();
console.log(
alreadyDisabled ? 'The server is already out of maintenance mode!' : 'Maintenance mode has been disabled.',
);
}
}

@ -150,6 +150,7 @@ export const endpointTags: Record<ApiTag, string> = {
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]:
'An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries.',
[ApiTag.Maintenance]: 'Maintenance mode allows you to put Immich in a read-only state to perform various operations.',
[ApiTag.Map]:
'Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data.',
[ApiTag.Memories]:

@ -11,6 +11,7 @@ import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
@ -49,6 +50,7 @@ export const controllers = [
FaceController,
JobController,
LibraryController,
MaintenanceController,
MapController,
MemoryController,
NotificationController,

@ -0,0 +1,49 @@
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
@Post('login')
@Endpoint({
summary: 'Log into maintenance mode',
description: 'Login with maintenance token or cookie to receive current information and perform further actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto {
throw new BadRequestException('Not in maintenance mode');
}
@Post()
@Endpoint({
summary: 'Set maintenance mode',
description: 'Put Immich into or take it out of maintenance mode',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async setMaintenanceMode(
@Auth() auth: AuthDto,
@Body() dto: SetMaintenanceModeDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
if (dto.action === MaintenanceAction.Start) {
const { jwt } = await this.service.startMaintenance(auth.user.name);
return respondWithCookie(res, undefined, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
});
}
}
}

@ -0,0 +1,16 @@
import { MaintenanceAction } from 'src/enum';
import { ValidateEnum, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto {
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
action!: MaintenanceAction;
}
export class MaintenanceLoginDto {
@ValidateString({ optional: true })
token?: string;
}
export class MaintenanceAuthDto {
username!: string;
}

@ -154,6 +154,7 @@ export class ServerConfigDto {
publicUsers!: boolean;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
maintenanceMode!: boolean;
}
export class ServerFeaturesDto {

@ -5,6 +5,7 @@ export enum AuthType {
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
AuthType = 'immich_auth_type',
IsAuthenticated = 'immich_is_authenticated',
SharedLinkToken = 'immich_shared_link_token',
@ -146,6 +147,8 @@ export enum Permission {
TimelineRead = 'timeline.read',
TimelineDownload = 'timeline.download',
Maintenance = 'maintenance',
MemoryCreate = 'memory.create',
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',
@ -285,6 +288,7 @@ export enum SystemMetadataKey {
FacialRecognitionState = 'facial-recognition-state',
MemoriesState = 'memories-state',
AdminOnboarding = 'admin-onboarding',
MaintenanceMode = 'maintenance-mode',
SystemConfig = 'system-config',
SystemFlags = 'system-flags',
VersionCheckState = 'version-check-state',
@ -477,6 +481,7 @@ export enum ImmichEnvironment {
export enum ImmichWorker {
Api = 'api',
Maintenance = 'maintenance',
Microservices = 'microservices',
}
@ -655,6 +660,15 @@ export enum DatabaseLock {
MemoryCreation = 777,
}
export enum MaintenanceAction {
Start = 'start',
End = 'end',
}
export enum ExitCode {
AppRestart = 7,
}
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
@ -801,6 +815,7 @@ export enum ApiTag {
Faces = 'Faces',
Jobs = 'Jobs',
Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)',
Map = 'Map',
Memories = 'Memories',
Notifications = 'Notifications',

@ -1,61 +1,151 @@
import { Kysely } from 'kysely';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { PostgresError } from 'postgres';
import { ImmichAdminModule } from 'src/app.module';
import { ImmichWorker, LogLevel } from 'src/enum';
import { ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type DB } from 'src/schema';
import { getKyselyConfig } from 'src/utils/database';
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
/**
* Manages worker lifecycle
*/
class Workers {
/**
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
/**
* Fail-safe in case anything dies during restart
*/
restarting = false;
let apiProcess: ChildProcess | undefined;
/**
* Boot all enabled workers
*/
async bootstrap() {
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
const onError = (name: string, error: Error) => {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
};
if (isMaintenanceMode) {
this.startWorker(ImmichWorker.Maintenance);
} else {
for (const worker of workers) {
this.startWorker(worker);
}
}
}
const onExit = (name: string, exitCode: number | null) => {
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
/**
* Initialise a short-lived Nest application to build configuration
* @returns System configuration
*/
private async isMaintenanceMode(): Promise<boolean> {
const { database } = new ConfigRepository().getEnv();
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
const systemMetadataRepository = new SystemMetadataRepository(kysely);
if (apiProcess && name !== ImmichWorker.Api) {
console.error('Killing api process');
apiProcess.kill('SIGTERM');
apiProcess = undefined;
try {
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
return value?.isMaintenanceMode || false;
} catch (error) {
// Table doesn't exist (migrations haven't run yet)
if (error instanceof PostgresError && error.code === '42P01') {
return false;
}
throw error;
} finally {
await kysely.destroy();
}
}
process.exit(exitCode);
};
/**
* Start an individual worker
* @param name Worker
*/
private startWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let anyWorker: Worker | ChildProcess;
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
function bootstrapWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
kill = (signal) => void worker.kill(signal);
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
let worker: Worker | ChildProcess;
if (name === ImmichWorker.Api) {
worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
apiProcess = worker;
} else {
worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
worker.on('error', (error) => onError(name, error));
worker.on('exit', (exitCode) => onExit(name, exitCode));
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
// restart immich server
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
// once all workers shut down, bootstrap again
if (Object.keys(this.workers).length === 0) {
void this.bootstrap();
this.restarting = false;
}
return;
}
// shutdown the entire process
delete this.workers[name];
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
}
}
process.exit(exitCode);
}
}
function bootstrap() {
function main() {
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
if (immichApp === 'immich-admin') {
process.title = 'immich_admin_cli';
process.env.IMMICH_LOG_LEVEL = LogLevel.Warn;
return CommandFactory.run(ImmichAdminModule);
}
@ -72,10 +162,7 @@ function bootstrap() {
}
process.title = 'immich';
const { workers } = new ConfigRepository().getEnv();
for (const worker of workers) {
bootstrapWorker(worker);
}
void new Workers().bootstrap();
}
void bootstrap();
void main();

@ -0,0 +1,58 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { MetadataKey } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const MaintenanceRoute = (options = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)];
return applyDecorators(...decorators);
};
export interface MaintenanceAuthRequest extends Request {
auth?: MaintenanceAuthDto;
}
export interface MaintenanceAuthenticatedRequest extends Request {
auth: MaintenanceAuthDto;
}
export const MaintenanceAuth = createParamDecorator((data, context: ExecutionContext): MaintenanceAuthDto => {
return context.switchToHttp().getRequest<MaintenanceAuthenticatedRequest>().auth;
});
@Injectable()
export class MaintenanceAuthGuard implements CanActivate {
constructor(
private logger: LoggingRepository,
private reflector: Reflector,
private service: MaintenanceWorkerService,
) {
this.logger.setContext(MaintenanceAuthGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
MetadataKey.AuthRoute,
targets,
);
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
request.auth = await this.service.authenticate(request.headers);
return true;
}
}

@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AppRepository } from 'src/repositories/app.repository';
import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const serverEvents = ['AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
AppRestartV1: [AppRestartEvent];
}
@WebSocketGateway({
cors: true,
path: '/api/socket.io',
transports: ['websocket'],
})
@Injectable()
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
@WebSocketServer()
private websocketServer?: Server;
constructor(
private logger: LoggingRepository,
private appRepository: AppRepository,
) {
this.logger.setContext(MaintenanceWebsocketRepository.name);
}
afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
}
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
this.websocketServer?.emit(event, ...data);
}
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
this.logger.debug(`Server event: ${event} (send)`);
this.websocketServer?.serverSideEmit(event, ...args);
}
handleConnection(client: Socket) {
this.logger.log(`Websocket Connect: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Websocket Disconnect: ${client.id}`);
}
}

@ -0,0 +1,43 @@
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie, MaintenanceAction } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@Controller()
export class MaintenanceWorkerController {
constructor(private service: MaintenanceWorkerService) {}
@Get('server/config')
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getSystemConfig();
}
@Post('admin/maintenance/login')
async maintenanceLogin(
@Req() request: Request,
@Body() dto: MaintenanceLoginDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<MaintenanceAuthDto> {
const token = dto.token ?? request.cookies[ImmichCookie.MaintenanceToken];
const auth = await this.service.login(token);
return respondWithCookie(res, auth, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: token }],
});
}
@Post('admin/maintenance')
@MaintenanceRoute()
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
if (dto.action === MaintenanceAction.End) {
await this.service.endMaintenance();
}
}
}

@ -0,0 +1,128 @@
import { UnauthorizedException } from '@nestjs/common';
import { SignJWT } from 'jose';
import { SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { automock, getMocks, ServiceMocks } from 'test/utils';
describe(MaintenanceWorkerService.name, () => {
let sut: MaintenanceWorkerService;
let mocks: ServiceMocks;
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository;
beforeEach(() => {
mocks = getMocks();
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false });
sut = new MaintenanceWorkerService(
mocks.logger as never,
mocks.app,
mocks.config,
mocks.systemMetadata as never,
maintenanceWorkerRepositoryMock,
);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getSystemConfig', () => {
it('should respond the server is in maintenance mode', async () => {
await expect(sut.getSystemConfig()).resolves.toMatchObject(
expect.objectContaining({
maintenanceMode: true,
}),
);
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
});
describe('logSecret', () => {
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
it('should log a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.logSecret()).resolves.toBeUndefined();
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
const [url] = mocks.logger.log.mock.lastCall!;
const token = RE_LOGIN_URL.exec(url)![1];
await expect(sut.login(token)).resolves.toEqual(
expect.objectContaining({
username: 'immich-admin',
}),
);
});
});
describe('authenticate', () => {
it('should fail without a cookie', async () => {
await expect(sut.authenticate({})).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should parse cookie properly', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(
sut.authenticate({
cookie: 'immich_maintenance_token=invalid-jwt',
}),
).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
});
describe('login', () => {
it('should fail without token', async () => {
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should fail with expired JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('0s')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
it('should succeed with valid JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({ _mockValue: true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('4h')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).resolves.toEqual(
expect.objectContaining({
_mockValue: true,
}),
);
});
});
describe('endMaintenance', () => {
it('should set maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.endMaintenance()).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
isMaintenanceMode: false,
});
});
});
});

@ -0,0 +1,161 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie';
import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ImmichCookie, SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type ApiService as _ApiService } from 'src/services/api.service';
import { type BaseService as _BaseService } from 'src/services/base.service';
import { type ServerService as _ServerService } from 'src/services/server.service';
import { MaintenanceModeState } from 'src/types';
import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
/**
* This service is available inside of maintenance mode to manage maintenance mode
*/
@Injectable()
export class MaintenanceWorkerService {
constructor(
protected logger: LoggingRepository,
private appRepository: AppRepository,
private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
) {
this.logger.setContext(this.constructor.name);
}
/**
* {@link _BaseService.configRepos}
*/
private get configRepos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
}
/**
* {@link _BaseService.prototype.getConfig}
*/
private getConfig(options: { withCache: boolean }) {
return getConfig(this.configRepos, options);
}
/**
* {@link _ServerService.getSystemConfig}
*/
async getSystemConfig() {
const config = await this.getConfig({ withCache: false });
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized: true,
isOnboarded: true,
externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: true,
};
}
/**
* {@link _ApiService.ssr}
*/
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
let index = '';
try {
index = readFileSync(resourcePaths.web.indexHtml).toString();
} catch {
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
return (request: Request, res: Response, next: NextFunction) => {
if (
request.url.startsWith('/api') ||
request.method.toLowerCase() !== 'get' ||
excludePaths.some((item) => request.url.startsWith(item))
) {
return next();
}
const maintenancePath = '/maintenance';
if (!request.url.startsWith(maintenancePath)) {
const params = new URLSearchParams();
params.set('continue', request.path);
return res.redirect(`${maintenancePath}?${params}`);
}
res.status(200).type('text/html').header('Cache-Control', 'no-store').send(index);
};
}
private async secret(): Promise<string> {
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
secret: string;
};
return state.secret;
}
async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
const url = await createMaintenanceLoginUrl(
baseUrl,
{
username: 'immich-admin',
},
await this.secret(),
);
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
}
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken];
return this.login(jwtToken);
}
async login(jwt?: string): Promise<MaintenanceAuthDto> {
if (!jwt) {
throw new UnauthorizedException('Missing JWT Token');
}
const secret = await this.secret();
try {
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret));
return result.payload;
} catch {
throw new UnauthorizedException('Invalid JWT Token');
}
}
async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
// => corresponds to notification.service.ts#onAppRestart
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
}

@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ExitCode } from 'src/enum';
@Injectable()
export class AppRepository {
private closeFn?: () => Promise<void>;
exitApp() {
/* eslint-disable unicorn/no-process-exit */
void this.closeFn?.().finally(() => process.exit(ExitCode.AppRestart));
// in exceptional circumstance, the application may hang
setTimeout(() => process.exit(ExitCode.AppRestart), 2000);
/* eslint-enable unicorn/no-process-exit */
}
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
}

@ -26,6 +26,7 @@ type EventMap = {
// app events
AppBootstrap: [];
AppShutdown: [];
AppRestart: [AppRestartEvent];
ConfigInit: [{ newConfig: SystemConfig }];
// config events
@ -96,6 +97,10 @@ type EventMap = {
WebsocketConnect: [{ userId: string }];
};
export type AppRestartEvent = {
isMaintenanceMode: boolean;
};
type JobSuccessEvent = { job: JobItem; response?: JobStatus };
type JobErrorEvent = { job: JobItem; error: Error | any };

@ -3,6 +3,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@ -56,6 +57,7 @@ export const repositories = [
AlbumUserRepository,
AuditRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetJobRepository,
ConfigRepository,

@ -12,11 +12,11 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
export const serverEvents = ['ConfigUpdate'] as const;
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
@ -36,6 +36,7 @@ export interface ClientEventMap {
on_session_delete: [string];
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
AppRestartV1: [AppRestartEvent];
}
export type AuthFn = (client: Socket) => Promise<AuthDto>;

@ -11,7 +11,7 @@ import { SharedLinkService } from 'src/services/shared-link.service';
import { VersionService } from 'src/services/version.service';
import { OpenGraphTags } from 'src/utils/misc';
const render = (index: string, meta: OpenGraphTags) => {
export const render = (index: string, meta: OpenGraphTags) => {
const [title, description, imageUrl] = [meta.title, meta.description, meta.imageUrl].map((item) =>
item ? sanitizeHtml(item, { allowedTags: [] }) : '',
);

@ -10,6 +10,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@ -66,6 +67,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
AlbumRepository,
AlbumUserRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetJobRepository,
AuditRepository,
@ -123,6 +125,7 @@ export class BaseService {
protected albumRepository: AlbumRepository,
protected albumUserRepository: AlbumUserRepository,
protected apiKeyRepository: ApiKeyRepository,
protected appRepository: AppRepository,
protected assetRepository: AssetRepository,
protected assetJobRepository: AssetJobRepository,
protected auditRepository: AuditRepository,

@ -1,3 +1,5 @@
import { jwtVerify } from 'jose';
import { SystemMetadataKey } from 'src/enum';
import { CliService } from 'src/services/cli.service';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -80,6 +82,82 @@ describe(CliService.name, () => {
});
});
describe('disableMaintenanceMode', () => {
it('should not do anything if not in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: true,
});
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should disable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
alreadyDisabled: false,
});
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
});
});
describe('enableMaintenanceMode', () => {
it('should not do anything if in maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
expect.objectContaining({
alreadyEnabled: true,
}),
);
expect(mocks.systemMetadata.set).toHaveBeenCalledTimes(0);
expect(mocks.event.emit).toHaveBeenCalledTimes(0);
});
it('should enable maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
expect.objectContaining({
alreadyEnabled: false,
}),
);
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/),
});
});
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
it('should return a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const result = await sut.enableMaintenanceMode();
expect(result).toEqual(
expect.objectContaining({
authUrl: expect.stringMatching(RE_LOGIN_URL),
alreadyEnabled: true,
}),
);
const token = RE_LOGIN_URL.exec(result.authUrl)![1];
await expect(jwtVerify(token, new TextEncoder().encode('secret'))).resolves.toEqual(
expect.objectContaining({
payload: expect.objectContaining({
username: 'cli-admin',
}),
}),
);
});
});
describe('disableOAuthLogin', () => {
it('should disable oauth login', async () => {
await sut.disableOAuthLogin();

@ -1,8 +1,12 @@
import { Injectable } from '@nestjs/common';
import { isAbsolute } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
@Injectable()
export class CliService extends BaseService {
@ -38,6 +42,63 @@ export class CliService extends BaseService {
await this.updateConfig(config);
}
async disableMaintenanceMode(): Promise<{ alreadyDisabled: boolean }> {
const currentState = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false as const });
if (!currentState.isMaintenanceMode) {
return {
alreadyDisabled: true,
};
}
const state = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
sendOneShotAppRestart(state);
return {
alreadyDisabled: false,
};
}
async enableMaintenanceMode(): Promise<{ authUrl: string; alreadyEnabled: boolean }> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
const payload: MaintenanceAuthDto = {
username: 'cli-admin',
};
const state = await this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false as const });
if (state.isMaintenanceMode) {
return {
authUrl: await createMaintenanceLoginUrl(baseUrl, payload, state.secret),
alreadyEnabled: true,
};
}
const secret = generateMaintenanceSecret();
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret,
});
sendOneShotAppRestart({
isMaintenanceMode: true,
});
return {
authUrl: await createMaintenanceLoginUrl(baseUrl, payload, secret),
alreadyEnabled: false,
};
}
async grantAdminAccess(email: string): Promise<void> {
const user = await this.userRepository.getByEmail(email);
if (!user) {

@ -14,6 +14,7 @@ import { DownloadService } from 'src/services/download.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { MapService } from 'src/services/map.service';
import { MediaService } from 'src/services/media.service';
import { MemoryService } from 'src/services/memory.service';
@ -63,6 +64,7 @@ export const services = [
DuplicateService,
JobService,
LibraryService,
MaintenanceService,
MapService,
MediaService,
MemoryService,

@ -0,0 +1,109 @@
import { SystemMetadataKey } from 'src/enum';
import { MaintenanceService } from 'src/services/maintenance.service';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MaintenanceService.name, () => {
let sut: MaintenanceService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(MaintenanceService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getMaintenanceMode', () => {
it('should return false if state unknown', async () => {
mocks.systemMetadata.get.mockResolvedValue(null);
await expect(sut.getMaintenanceMode()).resolves.toEqual({
isMaintenanceMode: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should return false if disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.getMaintenanceMode()).resolves.toEqual({
isMaintenanceMode: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
it('should return true if enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: '' });
await expect(sut.getMaintenanceMode()).resolves.toEqual({
isMaintenanceMode: true,
secret: '',
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
});
describe('startMaintenance', () => {
it('should set maintenance mode and return a secret', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.startMaintenance('admin')).resolves.toMatchObject({
jwt: expect.any(String),
});
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: true,
secret: expect.stringMatching(/^\w{128}$/),
});
expect(mocks.event.emit).toHaveBeenCalledWith('AppRestart', {
isMaintenanceMode: true,
});
});
});
describe('createLoginUrl', () => {
it('should fail outside of maintenance mode without secret', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(
sut.createLoginUrl({
username: '',
}),
).rejects.toThrowError('Not in maintenance mode');
});
it('should generate a login url with JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(
sut.createLoginUrl({
username: '',
}),
).resolves.toEqual(
expect.stringMatching(
/^https:\/\/my.immich.app\/maintenance\?token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/,
),
);
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(2);
});
it('should use the given secret', async () => {
await expect(
sut.createLoginUrl(
{
username: '',
},
'secret',
),
).resolves.toEqual(expect.stringMatching(/./));
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1);
});
});
});

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
/**
* This service is available outside of maintenance mode to manage maintenance mode
*/
@Injectable()
export class MaintenanceService extends BaseService {
getMaintenanceMode(): Promise<MaintenanceModeState> {
return this.systemMetadataRepository
.get(SystemMetadataKey.MaintenanceMode)
.then((state) => state ?? { isMaintenanceMode: false });
}
async startMaintenance(username: string): Promise<{ jwt: string }> {
const secret = generateMaintenanceSecret();
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret });
await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true });
return {
jwt: await signMaintenanceJwt(secret, {
username,
}),
};
}
@OnEvent({ name: 'AppRestart', server: true })
onRestart(): void {
this.appRepository.exitApp();
}
async createLoginUrl(auth: MaintenanceAuthDto, secret?: string): Promise<string> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
if (!secret) {
const state = await this.getMaintenanceMode();
if (!state.isMaintenanceMode) {
throw new Error('Not in maintenance mode');
}
secret = state.secret;
}
return await createMaintenanceLoginUrl(baseUrl, auth, secret);
}
}

@ -114,6 +114,15 @@ export class NotificationService extends BaseService {
this.websocketRepository.serverSend('ConfigUpdate', { oldConfig, newConfig });
}
@OnEvent({ name: 'AppRestart' })
onAppRestart(state: ArgOf<'AppRestart'>) {
this.websocketRepository.clientBroadcast('AppRestartV1', {
isMaintenanceMode: state.isMaintenanceMode,
});
this.websocketRepository.serverSend('AppRestart', state);
}
@OnEvent({ name: 'ConfigValidate', priority: -100 })
async onConfigValidate({ oldConfig, newConfig }: ArgOf<'ConfigValidate'>) {
try {

@ -166,6 +166,7 @@ describe(ServerService.name, () => {
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
});
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});

@ -130,6 +130,7 @@ export class ServerService extends BaseService {
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: false,
};
}

@ -493,6 +493,7 @@ export interface MemoryData {
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MaintenanceModeState = { isMaintenanceMode: true; secret: string } | { isMaintenanceMode: false };
export type MemoriesState = {
/** memories have already been created through this date */
lastOnThisDayDate: string;
@ -503,6 +504,7 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.AdminOnboarding]: { isOnboarded: boolean };
[SystemMetadataKey.FacialRecognitionState]: { lastRun?: string };
[SystemMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: Date };
[SystemMetadataKey.MaintenanceMode]: MaintenanceModeState;
[SystemMetadataKey.MediaLocation]: MediaLocation;
[SystemMetadataKey.ReverseGeocodingState]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.SystemConfig]: DeepPartial<SystemConfig>;

@ -0,0 +1,74 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { Server as SocketIO } from 'socket.io';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
export function sendOneShotAppRestart(state: AppRestartEvent): void {
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis(redis);
const subClient = pubClient.duplicate();
server.adapter(createAdapter(pubClient, subClient));
/**
* Keep trying until we manage to stop Immich
*
* Sometimes there appear to be communication
* issues between to the other servers.
*
* This issue only occurs with this method.
*/
async function tryTerminate() {
while (true) {
try {
const responses = await server.serverSideEmitWithAck('AppRestart', state);
if (responses.length > 0) {
return;
}
} catch (error) {
console.error(error);
console.error('Encountered an error while telling Immich to stop.');
}
console.info(
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
);
await new Promise((r) => setTimeout(r, 1e3));
}
}
// => corresponds to notification.service.ts#onAppRestart
server.emit('AppRestartV1', state, () => {
void tryTerminate().finally(() => {
pubClient.disconnect();
subClient.disconnect();
});
});
}
export async function createMaintenanceLoginUrl(
baseUrl: string,
auth: MaintenanceAuthDto,
secret: string,
): Promise<string> {
return `${baseUrl}/maintenance?token=${await signMaintenanceJwt(secret, auth)}`;
}
export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDto): Promise<string> {
const alg = 'HS256';
return await new SignJWT({ ...data })
.setProtectedHeader({ alg })
.setIssuedAt()
.setExpirationTime('4h')
.sign(new TextEncoder().encode(secret));
}
export function generateMaintenanceSecret(): string {
return randomBytes(64).toString('hex');
}

@ -15,6 +15,7 @@ export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values
const cookieOptions: Record<ImmichCookie, CookieOptions> = {
[ImmichCookie.AuthType]: defaults,
[ImmichCookie.AccessToken]: defaults,
[ImmichCookie.MaintenanceToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
[ImmichCookie.OAuthState]: defaults,
[ImmichCookie.OAuthCodeVerifier]: defaults,
// no httpOnly so that the client can know the auth state

@ -1,69 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { ApiModule } from 'src/app.module';
import { excludePaths, serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError, useSwagger } from 'src/utils/misc';
import { isStartUpError } from 'src/utils/misc';
async function bootstrap() {
process.title = 'immich-api';
const { telemetry, network } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.apiPort);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths } = configRepository.getEnv();
logger.setContext('Bootstrap');
app.useLogger(logger);
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
}
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() });
app.setGlobalPrefix('api', { exclude: excludePaths });
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,
extensions: [],
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
}
app.use(app.get(ApiService).ssr(excludePaths));
app.use(compression());
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
app.get(AppRepository).setCloseFn(() => app.close());
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
void configureExpress(app, {
ssr: ApiService,
});
}
bootstrap().catch((error) => {

@ -0,0 +1,29 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { configureExpress, configureTelemetry } from 'src/app.common';
import { MaintenanceModule } from 'src/app.module';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AppRepository } from 'src/repositories/app.repository';
import { isStartUpError } from 'src/utils/misc';
async function bootstrap() {
process.title = 'immich-maintenance';
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
app.get(AppRepository).setCloseFn(() => app.close());
void configureExpress(app, {
permitSwaggerWrite: false,
ssr: MaintenanceWorkerService,
});
void app.get(MaintenanceWorkerService).logSecret();
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

@ -3,6 +3,7 @@ import { isMainThread } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
import { serverVersion } from 'src/constants';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
@ -17,6 +18,7 @@ export async function bootstrap() {
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
const logger = await app.resolve(LoggingRepository);
const configRepository = app.get(ConfigRepository);
app.get(AppRepository).setCloseFn(() => app.close());
const { environment, host } = configRepository.getEnv();

@ -19,6 +19,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@ -212,6 +213,7 @@ export type ServiceOverrides = {
album: AlbumRepository;
albumUser: AlbumUserRepository;
apiKey: ApiKeyRepository;
app: AppRepository;
audit: AuditRepository;
asset: AssetRepository;
assetJob: AssetJobRepository;
@ -271,10 +273,7 @@ type Constructor<Type, Args extends Array<any>> = {
new (...deps: Args): Type;
};
export const newTestService = <T extends BaseService>(
Service: Constructor<T, BaseServiceArgs>,
overrides: Partial<ServiceOverrides> = {},
) => {
export const getMocks = () => {
const loggerMock = { setContext: () => {} };
const configMock = { getEnv: () => ({}) };
@ -291,6 +290,7 @@ export const newTestService = <T extends BaseService>(
albumUser: automock(AlbumUserRepository),
asset: newAssetRepositoryMock(),
assetJob: automock(AssetJobRepository),
app: automock(AppRepository, { strict: false }),
config: newConfigRepositoryMock(),
database: newDatabaseRepositoryMock(),
downloadRepository: automock(DownloadRepository, { strict: false }),
@ -338,6 +338,15 @@ export const newTestService = <T extends BaseService>(
workflow: automock(WorkflowRepository, { strict: true }),
};
return mocks;
};
export const newTestService = <T extends BaseService>(
Service: Constructor<T, BaseServiceArgs>,
overrides: Partial<ServiceOverrides> = {},
) => {
const mocks = getMocks();
const sut = new Service(
overrides.logger || (mocks.logger as As<LoggingRepository>),
overrides.access || (mocks.access as IAccessRepository as AccessRepository),
@ -345,6 +354,7 @@ export const newTestService = <T extends BaseService>(
overrides.album || (mocks.album as As<AlbumRepository>),
overrides.albumUser || (mocks.albumUser as As<AlbumUserRepository>),
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
overrides.app || (mocks.app as As<AppRepository>),
overrides.asset || (mocks.asset as As<AssetRepository>),
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
overrides.audit || (mocks.audit as As<AuditRepository>),

@ -0,0 +1,35 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
disabled?: boolean;
}
let { disabled = false }: Props = $props();
async function start() {
try {
await setMaintenanceMode({
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
});
} catch (error) {
handleError(error, $t('admin.maintenance_start_error'));
}
}
</script>
<div>
<div in:fade={{ duration: 500 }}>
<div class="ms-4 mt-4 flex items-end gap-4">
<Button shape="round" type="submit" {disabled} size="small" onclick={start}
>{$t('admin.maintenance_start')}</Button
>
</div>
</div>
</div>

@ -59,6 +59,8 @@ export enum AppRoute {
FOLDERS = '/folders',
TAGS = '/tags',
LOCKED = '/locked',
MAINTENANCE = '/maintenance',
}
export enum ProjectionType {

@ -0,0 +1,16 @@
<script lang="ts">
import { Modal, ModalBody } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
const { onClose }: Props = $props();
</script>
<Modal size="small" title={$t('server_restarting_title')} {onClose} icon={false}>
<ModalBody>
<div class="font-medium">{$t('server_restarting_description')}</div>
</ModalBody>
</Modal>

@ -0,0 +1,4 @@
import { type MaintenanceAuthDto } from '@immich/sdk';
import { writable } from 'svelte/store';
export const maintenanceAuth = writable<MaintenanceAuthDto>();

@ -1,3 +1,5 @@
import { page } from '$app/state';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { createEventEmitter } from '$lib/utils/eventemitter';
@ -13,6 +15,11 @@ export interface ReleaseEvent {
serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto;
}
interface AppRestartEvent {
isMaintenanceMode: boolean;
}
export interface Events {
on_upload_success: (asset: AssetResponseDto) => void;
on_user_delete: (id: string) => void;
@ -28,6 +35,8 @@ export interface Events {
on_new_release: (newRelease: ReleaseEvent) => void;
on_session_delete: (sessionId: string) => void;
on_notification: (notification: NotificationDto) => void;
AppRestartV1: (event: AppRestartEvent) => void;
}
const websocket: Socket<Events> = io({
@ -42,6 +51,7 @@ export const websocketStore = {
connected: writable<boolean>(false),
serverVersion: writable<ServerVersionResponseDto>(),
release: writable<ReleaseEvent>(),
serverRestarting: writable<undefined | AppRestartEvent>(),
};
export const websocketEvents = createEventEmitter(websocket);
@ -50,6 +60,7 @@ websocket
.on('connect', () => websocketStore.connected.set(true))
.on('disconnect', () => websocketStore.connected.set(false))
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
.on('AppRestartV1', (mode) => websocketStore.serverRestarting.set(mode))
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
.on('on_session_delete', () => authManager.logout())
.on('on_notification', () => notificationManager.refresh())
@ -57,11 +68,9 @@ websocket
export const openWebsocketConnection = () => {
try {
if (!get(user)) {
return;
if (get(user) || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
websocket.connect();
}
websocket.connect();
} catch (error) {
console.log('Cannot connect to websocket', error);
}

@ -0,0 +1,33 @@
import { AppRoute } from '$lib/constants';
import { maintenanceAuth as maintenanceAuth$ } from '$lib/stores/maintenance.store';
import { maintenanceLogin } from '@immich/sdk';
export function maintenanceCreateUrl(url: URL) {
const target = new URL(AppRoute.MAINTENANCE, url.origin);
target.searchParams.set('continue', url.pathname + url.search);
return target.href;
}
export function maintenanceReturnUrl(searchParams: URLSearchParams) {
return searchParams.get('continue') ?? '/';
}
export function maintenanceShouldRedirect(maintenanceMode: boolean, currentUrl: URL | Location) {
return maintenanceMode !== currentUrl.pathname.startsWith(AppRoute.MAINTENANCE);
}
export const loadMaintenanceAuth = async () => {
const query = new URLSearchParams(location.search);
try {
const auth = await maintenanceLogin({
maintenanceLoginDto: {
token: query.get('token') ?? undefined,
},
});
maintenanceAuth$.set(auth);
} catch {
// silently fail
}
};

@ -12,8 +12,11 @@ async function _init(fetch: Fetch) {
// https://github.com/oazapfts/oazapfts/blob/main/README.md#fetch-options
defaults.fetch = fetch;
await initLanguage();
await featureFlagsManager.init();
await serverConfigManager.init();
if (!serverConfigManager.value.maintenanceMode) {
await featureFlagsManager.init();
}
}
export const init = memoize(_init, () => 'singlevalue');

@ -7,8 +7,10 @@
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import ServerRestartingModal from '$lib/modals/ServerRestartingModal.svelte';
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
import { user } from '$lib/stores/user.store';
import {
@ -18,6 +20,7 @@
type ReleaseEvent,
} from '$lib/stores/websocket';
import { copyToClipboard, getReleaseType, semverToName } from '$lib/utils';
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { modalManager, setTranslations } from '@immich/ui';
import { onMount, type Snippet } from 'svelte';
@ -70,14 +73,14 @@
showNavigationLoadingBar = false;
});
run(() => {
if ($user) {
if ($user || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
openWebsocketConnection();
} else {
closeWebsocketConnection();
}
});
const { release } = websocketStore;
const { release, serverRestarting } = websocketStore;
const handleRelease = async (release?: ReleaseEvent) => {
if (!release?.isAvailable || !$user.isAdmin) {
@ -102,6 +105,27 @@
};
$effect(() => void handleRelease($release));
serverRestarting.subscribe((isRestarting) => {
if (!isRestarting) {
return;
}
if (maintenanceShouldRedirect(isRestarting.isMaintenanceMode, location)) {
modalManager.show(ServerRestartingModal, {}).catch((error) => console.error('Error [ServerRestartBox]:', error));
// we will be disconnected momentarily
// wait for reconnect then reload
let waiting = false;
websocketStore.connected.subscribe((connected) => {
if (!connected) {
waiting = true;
} else if (connected && waiting) {
location.reload();
}
});
}
});
</script>
<svelte:head>

@ -1,13 +1,22 @@
import { goto } from '$app/navigation';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { init } from '$lib/utils/server';
import type { LayoutLoad } from './$types';
export const ssr = false;
export const csr = true;
export const load = (async ({ fetch }) => {
export const load = (async ({ fetch, url }) => {
let error;
try {
await init(fetch);
if (maintenanceShouldRedirect(serverConfigManager.value.maintenanceMode, url)) {
await goto(
serverConfigManager.value.maintenanceMode ? maintenanceCreateUrl(url) : maintenanceReturnUrl(url.searchParams),
);
}
} catch (initError) {
error = initError;
}

@ -12,6 +12,11 @@ export const csr = true;
export const load = (async ({ fetch }) => {
try {
await init(fetch);
if (serverConfigManager.value.maintenanceMode) {
redirect(302, AppRoute.MAINTENANCE);
}
const authenticated = await loadUser();
if (authenticated) {
redirect(302, AppRoute.PHOTOS);

@ -7,6 +7,7 @@
import LibrarySettings from '$lib/components/admin-settings/LibrarySettings.svelte';
import LoggingSettings from '$lib/components/admin-settings/LoggingSettings.svelte';
import MachineLearningSettings from '$lib/components/admin-settings/MachineLearningSettings.svelte';
import MaintenanceSettings from '$lib/components/admin-settings/MaintenanceSettings.svelte';
import MapSettings from '$lib/components/admin-settings/MapSettings.svelte';
import MetadataSettings from '$lib/components/admin-settings/MetadataSettings.svelte';
import NewVersionCheckSettings from '$lib/components/admin-settings/NewVersionCheckSettings.svelte';
@ -40,6 +41,7 @@
mdiLockOutline,
mdiMapMarkerOutline,
mdiPaletteOutline,
mdiRestore,
mdiRobotOutline,
mdiServerOutline,
mdiSync,
@ -113,6 +115,13 @@
key: 'machine-learning',
icon: mdiRobotOutline,
},
{
component: MaintenanceSettings,
title: $t('admin.maintenance_settings'),
subtitle: $t('admin.maintenance_settings_description'),
key: 'maintenance',
icon: mdiRestore,
},
{
component: MapSettings,
title: $t('admin.map_gps_settings'),

@ -0,0 +1,55 @@
<script lang="ts">
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { maintenanceAuth } from '$lib/stores/maintenance.store';
import { handleError } from '$lib/utils/handle-error';
import { MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
import { Button, Heading, Link } from '@immich/ui';
import { t } from 'svelte-i18n';
// strip token from URL after load
const url = new URL(location.href);
if (url.searchParams.get('token')) {
url.searchParams.delete('token');
history.replaceState({}, document.title, url);
}
async function end() {
try {
await setMaintenanceMode({
setMaintenanceModeDto: {
action: MaintenanceAction.End,
},
});
} catch (error) {
handleError(error, $t('maintenance_end_error'));
}
}
</script>
<AuthPageLayout>
<div class="flex flex-col place-items-center text-center gap-4">
<Heading size="large" color="primary" tag="h1">{$t('maintenance_title')}</Heading>
<p>
<FormatMessage key="maintenance_description">
{#snippet children({ tag, message })}
{#if tag === 'link'}
<Link href="https://docs.immich.app/administration/maintenance-mode">
{message}
</Link>
{/if}
{/snippet}
</FormatMessage>
</p>
{#if $maintenanceAuth}
<p>
{$t('maintenance_logged_in_as', {
values: {
user: $maintenanceAuth.username,
},
})}
</p>
<Button onclick={end}>{$t('maintenance_end')}</Button>
{/if}
</div>
</AuthPageLayout>

@ -0,0 +1,6 @@
import { loadMaintenanceAuth } from '$lib/utils/maintenance';
import type { PageLoad } from '../admin/$types';
export const load = (async () => {
await loadMaintenanceAuth();
}) satisfies PageLoad;