diff --git a/docs/docs/administration/maintenance-mode.md b/docs/docs/administration/maintenance-mode.md new file mode 100644 index 000000000..300c27ca4 --- /dev/null +++ b/docs/docs/administration/maintenance-mode.md @@ -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. diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index 3838635c2..8c58baac1 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -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= +``` + Enable OAuth login ``` diff --git a/e2e/src/api/specs/maintenance.e2e-spec.ts b/e2e/src/api/specs/maintenance.e2e-spec.ts new file mode 100644 index 000000000..b6c7540bc --- /dev/null +++ b/e2e/src/api/specs/maintenance.e2e-spec.ts @@ -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(); + }); + }); +}); diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index adf252685..3dd6f15e7 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -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', }); diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 27e609120..958548435 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -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, diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 8f34bbe40..e3e738146 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -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) => + 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 }); diff --git a/e2e/src/web/specs/maintenance.e2e-spec.ts b/e2e/src/web/specs/maintenance.e2e-spec.ts new file mode 100644 index 000000000..e8ee33b02 --- /dev/null +++ b/e2e/src/web/specs/maintenance.e2e-spec.ts @@ -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'); + }); +}); diff --git a/i18n/en.json b/i18n/en.json index 6da205d85..5edbd8797 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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 maintenance mode.", + "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", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4e34f66a8..9fa1b4858 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f3db370c9..a47d9ddf9 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/maintenance_admin_api.dart b/mobile/openapi/lib/api/maintenance_admin_api.dart new file mode 100644 index 000000000..7e46f96c6 Binary files /dev/null and b/mobile/openapi/lib/api/maintenance_admin_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 91dc670d1..c0dcf542e 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 4b33a0721..e6d39d5eb 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ diff --git a/mobile/openapi/lib/model/maintenance_action.dart b/mobile/openapi/lib/model/maintenance_action.dart new file mode 100644 index 000000000..9be628961 Binary files /dev/null and b/mobile/openapi/lib/model/maintenance_action.dart differ diff --git a/mobile/openapi/lib/model/maintenance_auth_dto.dart b/mobile/openapi/lib/model/maintenance_auth_dto.dart new file mode 100644 index 000000000..919da5502 Binary files /dev/null and b/mobile/openapi/lib/model/maintenance_auth_dto.dart differ diff --git a/mobile/openapi/lib/model/maintenance_login_dto.dart b/mobile/openapi/lib/model/maintenance_login_dto.dart new file mode 100644 index 000000000..45f56bd3b Binary files /dev/null and b/mobile/openapi/lib/model/maintenance_login_dto.dart differ diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 8b05de523..0a2f0d179 100644 Binary files a/mobile/openapi/lib/model/permission.dart and b/mobile/openapi/lib/model/permission.dart differ diff --git a/mobile/openapi/lib/model/server_config_dto.dart b/mobile/openapi/lib/model/server_config_dto.dart index 01c82af4d..8e701472b 100644 Binary files a/mobile/openapi/lib/model/server_config_dto.dart and b/mobile/openapi/lib/model/server_config_dto.dart differ diff --git a/mobile/openapi/lib/model/set_maintenance_mode_dto.dart b/mobile/openapi/lib/model/set_maintenance_mode_dto.dart new file mode 100644 index 000000000..c72433752 Binary files /dev/null and b/mobile/openapi/lib/model/set_maintenance_mode_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d42aa0baa..e89967ae5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0664d2699..34ceb5650 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0e4b5ea7..28ac12ab7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/package.json b/server/package.json index a252a53b8..6de6531c6 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/app.common.ts b/server/src/app.common.ts new file mode 100644 index 000000000..934c13343 --- /dev/null +++ b/server/src/app.common.ts @@ -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}] `); +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index f80a47bb7..caa4ea4b6 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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 { diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index 46a8d13e3..2aef2e8c8 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -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, diff --git a/server/src/commands/maintenance-mode.ts b/server/src/commands/maintenance-mode.ts new file mode 100644 index 000000000..3416acf05 --- /dev/null +++ b/server/src/commands/maintenance-mode.ts @@ -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 { + 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 { + const { alreadyDisabled } = await this.service.disableMaintenanceMode(); + + console.log( + alreadyDisabled ? 'The server is already out of maintenance mode!' : 'Maintenance mode has been disabled.', + ); + } +} diff --git a/server/src/constants.ts b/server/src/constants.ts index d624557c5..68534c00e 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -150,6 +150,7 @@ export const endpointTags: Record = { '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]: diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index c0c0461fb..d5811de48 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -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, diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts new file mode 100644 index 000000000..7b2aa1758 --- /dev/null +++ b/server/src/controllers/maintenance.controller.ts @@ -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 { + 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 }], + }); + } + } +} diff --git a/server/src/dtos/maintenance.dto.ts b/server/src/dtos/maintenance.dto.ts new file mode 100644 index 000000000..fe6960c0a --- /dev/null +++ b/server/src/dtos/maintenance.dto.ts @@ -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; +} diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index d7589c8a2..e98cb2edf 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -154,6 +154,7 @@ export class ServerConfigDto { publicUsers!: boolean; mapDarkStyleUrl!: string; mapLightStyleUrl!: string; + maintenanceMode!: boolean; } export class ServerFeaturesDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 6055ee85b..d397f9d2a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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', diff --git a/server/src/main.ts b/server/src/main.ts index 68ea396e7..47185e846 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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 Promise | void }>> = {}; -let apiProcess: ChildProcess | undefined; + /** + * Fail-safe in case anything dies during restart + */ + restarting = false; -const onError = (name: string, error: Error) => { - console.error(`${name} worker error: ${error}, stack: ${error.stack}`); -}; + /** + * Boot all enabled workers + */ + async bootstrap() { + const isMaintenanceMode = await this.isMaintenanceMode(); + const { workers } = new ConfigRepository().getEnv(); -const onExit = (name: string, exitCode: number | null) => { - if (exitCode !== 0) { - console.error(`${name} worker exited with code ${exitCode}`); - - if (apiProcess && name !== ImmichWorker.Api) { - console.error('Killing api process'); - apiProcess.kill('SIGTERM'); - apiProcess = undefined; + if (isMaintenanceMode) { + this.startWorker(ImmichWorker.Maintenance); + } else { + for (const worker of workers) { + this.startWorker(worker); + } } } - process.exit(exitCode); -}; + /** + * Initialise a short-lived Nest application to build configuration + * @returns System configuration + */ + private async isMaintenanceMode(): Promise { + const { database } = new ConfigRepository().getEnv(); + const kysely = new Kysely(getKyselyConfig(database.config)); + const systemMetadataRepository = new SystemMetadataRepository(kysely); -function bootstrapWorker(name: ImmichWorker) { - console.log(`Starting ${name} worker`); + 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; + } - // eslint-disable-next-line unicorn/prefer-module - const basePath = dirname(__filename); - const workerFile = join(basePath, 'workers', `${name}.js`); - - 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); + throw error; + } finally { + await kysely.destroy(); + } } - worker.on('error', (error) => onError(name, error)); - worker.on('exit', (exitCode) => onExit(name, 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; + + if (name === ImmichWorker.Api) { + const worker = fork(workerFile, [], { + execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)), + }); + + kill = (signal) => void worker.kill(signal); + anyWorker = worker; + } else { + const 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 }; + } + + 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(); diff --git a/server/src/maintenance/maintenance-auth.guard.ts b/server/src/maintenance/maintenance-auth.guard.ts new file mode 100644 index 000000000..08aaad516 --- /dev/null +++ b/server/src/maintenance/maintenance-auth.guard.ts @@ -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().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 { + const targets = [context.getHandler()]; + const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>( + MetadataKey.AuthRoute, + targets, + ); + if (!options) { + return true; + } + + const request = context.switchToHttp().getRequest(); + request.auth = await this.service.authenticate(request.headers); + + return true; + } +} diff --git a/server/src/maintenance/maintenance-websocket.repository.ts b/server/src/maintenance/maintenance-websocket.repository.ts new file mode 100644 index 000000000..5d8368cf6 --- /dev/null +++ b/server/src/maintenance/maintenance-websocket.repository.ts @@ -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(event: T, ...data: ClientEventMap[T]) { + this.websocketServer?.emit(event, ...data); + } + + serverSend(event: T, ...args: ArgsOf): 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}`); + } +} diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts new file mode 100644 index 000000000..e6143b771 --- /dev/null +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -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 { + 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 { + 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 { + if (dto.action === MaintenanceAction.End) { + await this.service.endMaintenance(); + } + } +} diff --git a/server/src/maintenance/maintenance-worker.service.spec.ts b/server/src/maintenance/maintenance-worker.service.spec.ts new file mode 100644 index 000000000..dd5b98421 --- /dev/null +++ b/server/src/maintenance/maintenance-worker.service.spec.ts @@ -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, + }); + }); + }); +}); diff --git a/server/src/maintenance/maintenance-worker.service.ts b/server/src/maintenance/maintenance-worker.service.ts new file mode 100644 index 000000000..c03231c27 --- /dev/null +++ b/server/src/maintenance/maintenance-worker.service.ts @@ -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 { + const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as { + secret: string; + }; + + return state.secret; + } + + async logSecret(): Promise { + 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 { + const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken]; + return this.login(jwtToken); + } + + async login(jwt?: string): Promise { + if (!jwt) { + throw new UnauthorizedException('Missing JWT Token'); + } + + const secret = await this.secret(); + + try { + const result = await jwtVerify(jwt, new TextEncoder().encode(secret)); + return result.payload; + } catch { + throw new UnauthorizedException('Invalid JWT Token'); + } + } + + async endMaintenance(): Promise { + 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(); + } +} diff --git a/server/src/repositories/app.repository.ts b/server/src/repositories/app.repository.ts new file mode 100644 index 000000000..e6181ef7f --- /dev/null +++ b/server/src/repositories/app.repository.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { ExitCode } from 'src/enum'; + +@Injectable() +export class AppRepository { + private closeFn?: () => Promise; + + 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) { + this.closeFn = fn; + } +} diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 80d411c5a..fbc281ccb 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -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 }; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index c69536a32..c59110d67 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -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, diff --git a/server/src/repositories/websocket.repository.ts b/server/src/repositories/websocket.repository.ts index 030659772..d87bf7635 100644 --- a/server/src/repositories/websocket.repository.ts +++ b/server/src/repositories/websocket.repository.ts @@ -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; diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 0ec2a65f9..ed1b4095d 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -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: [] }) : '', ); diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 2c6d07b63..9c422818b 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -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, diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 1140d4460..49fa5cf5b 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -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(); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 38144e95b..3d248edc7 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -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 { const user = await this.userRepository.getByEmail(email); if (!user) { diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 9d09bdaa5..eeb842404 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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, diff --git a/server/src/services/maintenance.service.spec.ts b/server/src/services/maintenance.service.spec.ts new file mode 100644 index 000000000..cc497a6ea --- /dev/null +++ b/server/src/services/maintenance.service.spec.ts @@ -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); + }); + }); +}); diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts new file mode 100644 index 000000000..e6808300b --- /dev/null +++ b/server/src/services/maintenance.service.ts @@ -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 { + 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 { + 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); + } +} diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 8276f141a..ee87fcf77 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -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 { diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 8e39f09c6..6e1187a90 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -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(); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 5c3669dcb..af4d70606 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -130,6 +130,7 @@ export class ServerService extends BaseService { publicUsers: config.server.publicUsers, mapDarkStyleUrl: config.map.darkStyle, mapLightStyleUrl: config.map.lightStyle, + maintenanceMode: false, }; } diff --git a/server/src/types.ts b/server/src/types.ts index ad947e377..dd3d25a7c 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -493,6 +493,7 @@ export interface MemoryData { export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; export type SystemFlags = { mountChecks: Record }; +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; diff --git a/server/src/utils/maintenance.ts b/server/src/utils/maintenance.ts new file mode 100644 index 000000000..22de2e408 --- /dev/null +++ b/server/src/utils/maintenance.ts @@ -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 { + return `${baseUrl}/maintenance?token=${await signMaintenanceJwt(secret, auth)}`; +} + +export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDto): Promise { + 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'); +} diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts index c5f51c385..d5356285f 100644 --- a/server/src/utils/response.ts +++ b/server/src/utils/response.ts @@ -15,6 +15,7 @@ export const respondWithCookie = (res: Response, body: T, { isSecure, values const cookieOptions: Record = { [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 diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index f56adf3b6..99c08c0fa 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -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(ApiModule, { bufferLogs: true }); - const logger = await app.resolve(LoggingRepository); - const configRepository = app.get(ConfigRepository); + app.get(AppRepository).setCloseFn(() => app.close()); - 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; - - logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `); + void configureExpress(app, { + ssr: ApiService, + }); } bootstrap().catch((error) => { diff --git a/server/src/workers/maintenance.ts b/server/src/workers/maintenance.ts new file mode 100644 index 000000000..fcfe99012 --- /dev/null +++ b/server/src/workers/maintenance.ts @@ -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(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); +}); diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts index 40a85a276..8f06b4b0b 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -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(); diff --git a/server/test/utils.ts b/server/test/utils.ts index d584cf539..77853f897 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -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> = { new (...deps: Args): Type; }; -export const newTestService = ( - Service: Constructor, - overrides: Partial = {}, -) => { +export const getMocks = () => { const loggerMock = { setContext: () => {} }; const configMock = { getEnv: () => ({}) }; @@ -291,6 +290,7 @@ export const newTestService = ( 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 = ( workflow: automock(WorkflowRepository, { strict: true }), }; + return mocks; +}; + +export const newTestService = ( + Service: Constructor, + overrides: Partial = {}, +) => { + const mocks = getMocks(); + const sut = new Service( overrides.logger || (mocks.logger as As), overrides.access || (mocks.access as IAccessRepository as AccessRepository), @@ -345,6 +354,7 @@ export const newTestService = ( overrides.album || (mocks.album as As), overrides.albumUser || (mocks.albumUser as As), overrides.apiKey || (mocks.apiKey as As), + overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), overrides.assetJob || (mocks.assetJob as As), overrides.audit || (mocks.audit as As), diff --git a/web/src/lib/components/admin-settings/MaintenanceSettings.svelte b/web/src/lib/components/admin-settings/MaintenanceSettings.svelte new file mode 100644 index 000000000..592091c62 --- /dev/null +++ b/web/src/lib/components/admin-settings/MaintenanceSettings.svelte @@ -0,0 +1,35 @@ + + +
+
+
+ +
+
+
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 2075696b1..6ef26168b 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -59,6 +59,8 @@ export enum AppRoute { FOLDERS = '/folders', TAGS = '/tags', LOCKED = '/locked', + + MAINTENANCE = '/maintenance', } export enum ProjectionType { diff --git a/web/src/lib/modals/ServerRestartingModal.svelte b/web/src/lib/modals/ServerRestartingModal.svelte new file mode 100644 index 000000000..131675054 --- /dev/null +++ b/web/src/lib/modals/ServerRestartingModal.svelte @@ -0,0 +1,16 @@ + + + + +
{$t('server_restarting_description')}
+
+
diff --git a/web/src/lib/stores/maintenance.store.ts b/web/src/lib/stores/maintenance.store.ts new file mode 100644 index 000000000..9680a0636 --- /dev/null +++ b/web/src/lib/stores/maintenance.store.ts @@ -0,0 +1,4 @@ +import { type MaintenanceAuthDto } from '@immich/sdk'; +import { writable } from 'svelte/store'; + +export const maintenanceAuth = writable(); diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index c543fdb4f..9f01c6878 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -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 = io({ @@ -42,6 +51,7 @@ export const websocketStore = { connected: writable(false), serverVersion: writable(), release: writable(), + serverRestarting: writable(), }; 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); } diff --git a/web/src/lib/utils/maintenance.ts b/web/src/lib/utils/maintenance.ts new file mode 100644 index 000000000..f3d8bd1cb --- /dev/null +++ b/web/src/lib/utils/maintenance.ts @@ -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 + } +}; diff --git a/web/src/lib/utils/server.ts b/web/src/lib/utils/server.ts index 046ee496a..50fe4d72f 100644 --- a/web/src/lib/utils/server.ts +++ b/web/src/lib/utils/server.ts @@ -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'); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 8b63017c1..b936ce36a 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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(); + } + }); + } + }); diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index b5edece09..2d3bd92d4 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -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; } diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index bd6d1a62d..5ff4f58bf 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -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); diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 3f3d9a4f8..7fb7559be 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -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'), diff --git a/web/src/routes/maintenance/+page.svelte b/web/src/routes/maintenance/+page.svelte new file mode 100644 index 000000000..a1486c41b --- /dev/null +++ b/web/src/routes/maintenance/+page.svelte @@ -0,0 +1,55 @@ + + + +
+ {$t('maintenance_title')} +

+ + {#snippet children({ tag, message })} + {#if tag === 'link'} + + {message} + + {/if} + {/snippet} + +

+ {#if $maintenanceAuth} +

+ {$t('maintenance_logged_in_as', { + values: { + user: $maintenanceAuth.username, + }, + })} +

+ + {/if} +
+
diff --git a/web/src/routes/maintenance/+page.ts b/web/src/routes/maintenance/+page.ts new file mode 100644 index 000000000..8eec36fec --- /dev/null +++ b/web/src/routes/maintenance/+page.ts @@ -0,0 +1,6 @@ +import { loadMaintenanceAuth } from '$lib/utils/maintenance'; +import type { PageLoad } from '../admin/$types'; + +export const load = (async () => { + await loadMaintenanceAuth(); +}) satisfies PageLoad;