diff --git a/.github/labeler.yml b/.github/labeler.yml index 2a9abc7840381..c0c52f1d7e4b9 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -35,6 +35,4 @@ documentation: - machine-learning/app/** changelog:translation: - - changed-files: - - any-glob-to-any-file: - - web/src/lib/i18n/*.json + - head-branch: ['^chore/translations$'] diff --git a/README.md b/README.md index 85854577079f7..44c38e6d14813 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ For the mobile app, you can use `https://demo.immich.app/api` for the `Server En | LivePhoto/MotionPhoto backup and playback | Yes | Yes | | Support 360 degree image display | No | Yes | | User-defined storage structure | Yes | Yes | -| Public Sharing | No | Yes | +| Public Sharing | Yes | Yes | | Archive and Favorites | Yes | Yes | | Global Map | Yes | Yes | | Partner Sharing | Yes | Yes | diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 831b308a0c387..492f5b0c3d6e4 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -98,7 +98,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 healthcheck: test: redis-cli ping || exit 1 diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 509674f328b35..20126457aa47e 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -47,7 +47,7 @@ services: redis: container_name: immich_redis - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 927a95f5274c5..f0590385e4f90 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -48,7 +48,7 @@ services: redis: container_name: immich_redis - image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 healthcheck: test: redis-cli ping || exit 1 restart: always diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index cbeca0deca296..ce3d1d7ab1334 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -33,7 +33,7 @@ services: - 2285:3001 redis: - image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e + image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01 database: image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index f680aac826af3..12fb183c953d4 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -40,11 +40,10 @@ FROM prod-cpu AS prod-openvino RUN apt-get update && \ apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \ - wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \ - wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \ + wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \ dpkg -i *.deb && \ rm *.deb && \ apt-get remove wget -yqq && \ diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index fb96609a06047..2d8439e36a4f3 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 172; + CURRENT_PROJECT_VERSION = 173; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 172; + CURRENT_PROJECT_VERSION = 173; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 172; + CURRENT_PROJECT_VERSION = 173; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 138b0e426d251..b33be9a370df7 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.113.1 + 1.114.0 CFBundleSignature ???? CFBundleVersion - 172 + 173 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index ae01c47b846d9..0a238e1da5f6b 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -12,12 +12,14 @@ export class SystemMetadataEntity> { - [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; - [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; - [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; - [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; [SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; + [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; + [SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags; + [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; } diff --git a/server/src/enum.ts b/server/src/enum.ts index 28973e0205831..32254854e4c5a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -153,6 +153,7 @@ export enum SystemMetadataKey { FACIAL_RECOGNITION_STATE = 'facial-recognition-state', ADMIN_ONBOARDING = 'admin-onboarding', SYSTEM_CONFIG = 'system-config', + SYSTEM_FLAGS = 'system-flags', VERSION_CHECK_STATE = 'version-check-state', LICENSE = 'license', } diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 373f1091429d7..51b39b95a8c08 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -15,6 +15,7 @@ export enum VectorIndex { export enum DatabaseLock { GeodataImport = 100, Migrations = 200, + SystemFileMounts = 300, StorageTemplateMigration = 420, CLIPDimSize = 512, LibraryWatch = 1337, diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index bb2b0d9ab4bc9..ec6e776f5992b 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -21,6 +21,9 @@ type EmitEventMap = { 'asset.tag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }]; + // session events + 'session.delete': [{ sessionId: string }]; + // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; @@ -43,6 +46,7 @@ export enum ClientEvent { SERVER_VERSION = 'on_server_version', CONFIG_UPDATE = 'on_config_update', NEW_RELEASE = 'on_new_release', + SESSION_DELETE = 'on_session_delete', } export interface ClientEventMap { @@ -58,6 +62,7 @@ export interface ClientEventMap { [ClientEvent.SERVER_VERSION]: ServerVersionResponseDto; [ClientEvent.CONFIG_UPDATE]: Record; [ClientEvent.NEW_RELEASE]: ReleaseNotification; + [ClientEvent.SESSION_DELETE]: string; } export enum ServerEvent { @@ -77,7 +82,7 @@ export interface IEventRepository { /** * Send to connected clients for a specific user */ - clientSend(event: E, userId: string, data: ClientEventMap[E]): void; + clientSend(event: E, room: string, data: ClientEventMap[E]): void; /** * Send to all connected clients */ diff --git a/server/src/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts index dce75ffd25b03..80b37c3a5f182 100644 --- a/server/src/interfaces/map.interface.ts +++ b/server/src/interfaces/map.interface.ts @@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult { export interface IMapRepository { init(): Promise; - reverseGeocode(point: GeoPoint): Promise; + reverseGeocode(point: GeoPoint): Promise; getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; fetchStyle(url: string): Promise; } diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 04e7b89d1e138..39ff6ab4af33d 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -50,7 +50,7 @@ export interface ImmichTags extends Omit { export interface IMetadataRepository { teardown(): Promise; - readTags(path: string): Promise; + readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; getCountries(userIds: string[]): Promise>; diff --git a/server/src/main.ts b/server/src/main.ts index 7839bafd2fa78..ee4de1a259d19 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -17,7 +17,13 @@ async function bootstrapImmichAdmin() { function bootstrapWorker(name: string) { console.log(`Starting ${name} worker`); + const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`); + + worker.on('error', (error) => { + console.error(`${name} worker error: ${error}`); + }); + worker.on('exit', (exitCode) => { if (exitCode !== 0) { console.error(`${name} worker exited with code ${exitCode}`); diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 668eac48d9de9..9aa12e15dd560 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, @@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect private server?: Server; constructor( - private authService: AuthService, + private moduleRef: ModuleRef, private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect async handleConnection(client: Socket) { try { this.logger.log(`Websocket Connect: ${client.id}`); - const auth = await this.authService.authenticate({ + const auth = await this.moduleRef.get(AuthService).authenticate({ headers: client.request.headers, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, }); await client.join(auth.user.id); + if (auth.session) { + await client.join(auth.session.id); + } this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); @@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect } } - clientSend(event: E, userId: string, data: ClientEventMap[E]) { - this.server?.to(userId).emit(event, data); + clientSend(event: E, room: string, data: ClientEventMap[E]) { + this.server?.to(room).emit(event, data); } clientBroadcast(event: E, data: ClientEventMap[E]) { diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index da4e30d47cbf8..3508de720b2e1 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -124,7 +124,7 @@ export class MapRepository implements IMapRepository { } } - async reverseGeocode(point: GeoPoint): Promise { + async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); const response = await this.geodataPlacesRepository @@ -159,7 +159,7 @@ export class MapRepository implements IMapRepository { `Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`, ); - return null; + return { country: null, state: null, city: null }; } this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index abffc1b78527a..9902f04d9bfcf 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -36,11 +36,11 @@ export class MetadataRepository implements IMetadataRepository { await this.exiftool.end(); } - readTags(path: string): Promise { + readTags(path: string): Promise { return this.exiftool.read(path).catch((error) => { this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack); - return null; - }) as Promise; + return {}; + }) as Promise; } extractBinaryTag(path: string, tagName: string): Promise { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index f2fa0c520a30f..acc2d3459ccd1 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -20,6 +21,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; @@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; let cryptoMock: Mocked; + let eventMock: Mocked; let userMock: Mocked; let loggerMock: Mocked; let systemMock: Mocked; @@ -87,6 +90,7 @@ describe('AuthService', () => { } as any); cryptoMock = newCryptoRepositoryMock(); + eventMock = newEventRepositoryMock(); userMock = newUserRepositoryMock(); loggerMock = newLoggerRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); @@ -94,7 +98,7 @@ describe('AuthService', () => { shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); - sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); + sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); }); it('should be defined', () => { @@ -208,6 +212,7 @@ describe('AuthService', () => { }); expect(sessionMock.delete).toHaveBeenCalledWith('token123'); + expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 2b25decc07035..6eaf755d0eb49 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -75,6 +76,7 @@ export class AuthService { constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -114,6 +116,7 @@ export class AuthService { async logout(auth: AuthDto, authType: AuthType): Promise { if (auth.session) { await this.sessionRepository.delete(auth.session.id); + await this.eventRepository.emit('session.delete', { sessionId: auth.session.id }); } return { diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 52f6609772b9d..5b447c235539e 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -522,13 +522,13 @@ describe(MetadataService.name, () => { it('should extract the correct video orientation', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - metadataMock.readTags.mockResolvedValue(null); + metadataMock.readTags.mockResolvedValue({}); await sut.handleMetadataExtraction({ id: assetStub.video.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalledWith( - expect.objectContaining({ orientation: Orientation.Rotate270CW }), + expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }), ); }); @@ -814,6 +814,9 @@ describe(MetadataService.name, () => { projectionType: 'EQUIRECTANGULAR', timeZone: tags.tz, rating: tags.Rating, + country: null, + state: null, + city: null, }); expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 58e7b994480ac..cf51a332f844c 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored'; +import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored'; import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime'; import _ from 'lodash'; import { Duration } from 'luxon'; @@ -11,7 +11,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { ExifEntity } from 'src/entities/exif.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetType, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -30,7 +29,7 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IMapRepository } from 'src/interfaces/map.interface'; +import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -56,23 +55,16 @@ const EXIF_DATE_TAGS: Array = [ ]; export enum Orientation { - Horizontal = '1', - MirrorHorizontal = '2', - Rotate180 = '3', - MirrorVertical = '4', - MirrorHorizontalRotate270CW = '5', - Rotate90CW = '6', - MirrorHorizontalRotate90CW = '7', - Rotate270CW = '8', + Horizontal = 1, + MirrorHorizontal = 2, + Rotate180 = 3, + MirrorVertical = 4, + MirrorHorizontalRotate270CW = 5, + Rotate90CW = 6, + MirrorHorizontalRotate90CW = 7, + Rotate270CW = 8, } -type ExifEntityWithoutGeocodeAndTypeOrm = Omit & { - dateTimeOriginal: Date; -}; - -const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null); -const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null); - const validate = (value: T): NonNullable | null => { // handle lists of numbers if (Array.isArray(value)) { @@ -218,36 +210,73 @@ export class MetadataService { } async handleMetadataExtraction({ id }: IEntityJob): Promise { - const { metadata } = await this.configCore.getConfig({ withCache: true }); + const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { return JobStatus.FAILED; } - const { exifData, exifTags } = await this.exifData(asset); + const stats = await this.storageRepository.stat(asset.originalPath); - if (asset.type === AssetType.VIDEO) { - await this.applyVideoMetadata(asset, exifData); - } + const exifTags = await this.getExifTags(asset); + + this.logger.verbose('Exif Tags', exifTags); + + const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); + const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); + + const exifData = { + assetId: asset.id, + + // dates + dateTimeOriginal, + modifyDate, + timeZone, + + // gps + latitude, + longitude, + country, + state, + city, + + // image/file + fileSizeInByte: stats.size, + exifImageHeight: validate(exifTags.ImageHeight), + exifImageWidth: validate(exifTags.ImageWidth), + orientation: validate(exifTags.Orientation)?.toString() ?? null, + projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, + bitsPerSample: this.getBitsPerSample(exifTags), + colorspace: exifTags.ColorSpace ?? null, + + // camera + make: exifTags.Make ?? null, + model: exifTags.Model ?? null, + fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), + iso: validate(exifTags.ISO), + exposureTime: exifTags.ExposureTime ?? null, + lensModel: exifTags.LensModel ?? null, + fNumber: validate(exifTags.FNumber), + focalLength: validate(exifTags.FocalLength), + + // comments + description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), + profileDescription: exifTags.ProfileDescription || null, + rating: exifTags.Rating ?? null, + + // grouping + livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, + autoStackId: this.getAutoStackId(exifTags), + }; - await this.applyMotionPhotos(asset, exifTags); - await this.applyReverseGeocoding(asset, exifData); await this.applyTagList(asset, exifTags); + await this.applyMotionPhotos(asset, exifTags); await this.assetRepository.upsertExif(exifData); - const dateTimeOriginal = exifData.dateTimeOriginal; - let localDateTime = dateTimeOriginal ?? undefined; - - const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0; - - if (dateTimeOriginal && timeZoneOffset) { - localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000); - } - await this.assetRepository.update({ id: asset.id, - duration: asset.duration, + duration: exifTags.Duration?.toString() ?? null, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, }); @@ -338,25 +367,20 @@ export class MetadataService { return JobStatus.SUCCESS; } - private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { - const { latitude, longitude } = exifData; - const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true }); - if (!reverseGeocoding.enabled || !longitude || !latitude) { - return; + private async getExifTags(asset: AssetEntity): Promise { + const mediaTags = await this.repository.readTags(asset.originalPath); + const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {}; + const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {}; + + // make sure dates comes from sidecar + const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS); + if (sidecarDate) { + for (const tag of EXIF_DATE_TAGS) { + delete mediaTags[tag]; + } } - try { - const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude }); - if (!reverseGeocode) { - return; - } - Object.assign(exifData, reverseGeocode); - } catch (error: Error | any) { - this.logger.warn( - `Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`, - error?.stack, - ); - } + return { ...mediaTags, ...videoTags, ...sidecarTags }; } private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { @@ -576,66 +600,65 @@ export class MetadataService { ); } - private async exifData( - asset: AssetEntity, - ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> { - const stats = await this.storageRepository.stat(asset.originalPath); - const mediaTags = await this.repository.readTags(asset.originalPath); - const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null; + private getDates(asset: AssetEntity, exifTags: ImmichTags) { + const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS); + this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`); - // ensure date from sidecar is used if present - const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags); - if (mediaTags && hasDateOverride) { - for (const tag of EXIF_DATE_TAGS) { - delete mediaTags[tag]; - } + // created + let dateTimeOriginal = dateTime?.toDate(); + if (!dateTimeOriginal) { + this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`); + dateTimeOriginal = asset.fileCreatedAt; } - const exifTags = { ...mediaTags, ...sidecarTags }; + // timezone + let timeZone = exifTags.tz ?? null; + if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) { + // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly + // https://github.com/photostructure/exiftool-vendored.js/issues/203 + timeZone = 'UTC+0'; + } - this.logger.verbose('Exif Tags', exifTags); + if (timeZone) { + this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`); + } else { + this.logger.warn(`Asset ${asset.id} has no time zone information`); + } - const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags); - const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt; - const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue); + // offset minutes + const offsetMinutes = dateTime?.tzoffsetMinutes || 0; + let localDateTime = dateTimeOriginal; + if (offsetMinutes) { + localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000); + this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`); + } - const exifData = { - // altitude: tags.GPSAltitude ?? null, - assetId: asset.id, - bitsPerSample: this.getBitsPerSample(exifTags), - colorspace: exifTags.ColorSpace ?? null, + return { dateTimeOriginal, - description: String(exifTags.ImageDescription || exifTags.Description || '').trim(), - exifImageHeight: validate(exifTags.ImageHeight), - exifImageWidth: validate(exifTags.ImageWidth), - exposureTime: exifTags.ExposureTime ?? null, - fileSizeInByte: stats.size, - fNumber: validate(exifTags.FNumber), - focalLength: validate(exifTags.FocalLength), - fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), - iso: validate(exifTags.ISO), - latitude: validate(exifTags.GPSLatitude), - lensModel: exifTags.LensModel ?? null, - livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null, - autoStackId: this.getAutoStackId(exifTags), - longitude: validate(exifTags.GPSLongitude), - make: exifTags.Make ?? null, - model: exifTags.Model ?? null, - modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt, - orientation: validate(exifTags.Orientation)?.toString() ?? null, - profileDescription: exifTags.ProfileDescription || null, - projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, timeZone, - rating: exifTags.Rating ?? null, + localDateTime, + modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt, }; + } - if (exifData.latitude === 0 && exifData.longitude === 0) { - this.logger.warn('Exif data has latitude and longitude of 0, setting to null'); - exifData.latitude = null; - exifData.longitude = null; + private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) { + let latitude = validate(tags.GPSLatitude); + let longitude = validate(tags.GPSLongitude); + + // TODO take ref into account + + if (latitude === 0 && longitude === 0) { + this.logger.warn('Latitude and longitude of 0, setting to null'); + latitude = null; + longitude = null; } - return { exifData, exifTags }; + let result: ReverseGeocodeResult = { country: null, state: null, city: null }; + if (reverseGeocoding.enabled && longitude && latitude) { + result = await this.mapRepository.reverseGeocode({ latitude, longitude }); + } + + return { ...result, latitude, longitude }; } private getAutoStackId(tags: ImmichTags | null): string | null { @@ -645,28 +668,6 @@ export class MetadataService { return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null; } - private getDateTimeOriginal(tags: ImmichTags | Tags | null) { - return this.getDateTimeOriginalWithRawValue(tags).exifDate; - } - - private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } { - if (!tags) { - return { exifDate: null, rawValue: '' }; - } - const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS); - return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' }; - } - - private getTimeZone(exifTags: ImmichTags, rawValue: string) { - const timeZone = exifTags.tz ?? null; - if (timeZone == null && rawValue.endsWith('+00:00')) { - // exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly - // https://github.com/photostructure/exiftool-vendored.js/issues/203 - return 'UTC+0'; - } - return timeZone; - } - private getBitsPerSample(tags: ImmichTags): number | null { const bitDepthTags = [ tags.BitsPerSample, @@ -685,33 +686,37 @@ export class MetadataService { return bitsPerSample; } - private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { - const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath); + private async getVideoTags(originalPath: string) { + const { videoStreams, format } = await this.mediaRepository.probe(originalPath); + + const tags: Pick = {}; if (videoStreams[0]) { switch (videoStreams[0].rotation) { case -90: { - exifData.orientation = Orientation.Rotate90CW; + tags.Orientation = Orientation.Rotate90CW; break; } case 0: { - exifData.orientation = Orientation.Horizontal; + tags.Orientation = Orientation.Horizontal; break; } case 90: { - exifData.orientation = Orientation.Rotate270CW; + tags.Orientation = Orientation.Rotate270CW; break; } case 180: { - exifData.orientation = Orientation.Rotate180; + tags.Orientation = Orientation.Rotate180; break; } } } if (format.duration) { - asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); + tags.Duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); } + + return tags; } private async processSidecar(id: string, isSync: boolean): Promise { diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 5bcead0ff31ae..9d9f8f5fcfe2f 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -17,6 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; @@ -64,6 +66,7 @@ const configs = { describe(NotificationService.name, () => { let albumMock: Mocked; let assetMock: Mocked; + let eventMock: Mocked; let jobMock: Mocked; let loggerMock: Mocked; let notificationMock: Mocked; @@ -74,13 +77,23 @@ describe(NotificationService.name, () => { beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); + eventMock = newEventRepositoryMock(); jobMock = newJobRepositoryMock(); loggerMock = newLoggerRepositoryMock(); notificationMock = newNotificationRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock); + sut = new NotificationService( + eventMock, + systemMock, + notificationMock, + userMock, + jobMock, + loggerMock, + assetMock, + albumMock, + ); }); it('should work', () => { diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 274c91661ca2b..d450f8dc759a2 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -30,6 +30,7 @@ export class NotificationService { private configCore: SystemConfigCore; constructor( + @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -74,6 +75,12 @@ export class NotificationService { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } + @OnEmit({ event: 'session.delete' }) + onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { + // after the response is sent + setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); + } + async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index d9b4c8eefb3f3..b0f38554cb032 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -1,19 +1,29 @@ +import { SystemMetadataKey } from 'src/enum'; +import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { StorageService } from 'src/services/storage.service'; +import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; describe(StorageService.name, () => { let sut: StorageService; + let databaseMock: Mocked; let storageMock: Mocked; let loggerMock: Mocked; + let systemMock: Mocked; beforeEach(() => { + databaseMock = newDatabaseRepositoryMock(); storageMock = newStorageRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new StorageService(storageMock, loggerMock); + systemMock = newSystemMetadataRepositoryMock(); + + sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock); }); it('should work', () => { @@ -21,9 +31,35 @@ describe(StorageService.name, () => { }); describe('onBootstrap', () => { - it('should create the library folder on initialization', () => { - sut.onBootstrap(); + it('should enable mount folder checking', async () => { + systemMock.get.mockResolvedValue(null); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountFiles: true }); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + }); + + it('should throw an error if .immich is missing', async () => { + systemMock.get.mockResolvedValue({ mountFiles: true }); + storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); + + expect(storageMock.writeFile).not.toHaveBeenCalled(); + expect(systemMock.set).not.toHaveBeenCalled(); + }); + + it('should throw an error if .immich is present but read-only', async () => { + systemMock.get.mockResolvedValue({ mountFiles: true }); + storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount'); + + expect(systemMock.set).not.toHaveBeenCalled(); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index c3f2c06438340..a8f6a76e747e1 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,23 +1,52 @@ import { Inject, Injectable } from '@nestjs/common'; +import { join } from 'node:path'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { OnEmit } from 'src/decorators'; +import { SystemMetadataKey } from 'src/enum'; +import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { ImmichStartupError } from 'src/utils/events'; @Injectable() export class StorageService { constructor( + @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository, ) { this.logger.setContext(StorageService.name); } @OnEmit({ event: 'app.bootstrap' }) - onBootstrap() { - const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); - this.storageRepository.mkdirSync(libraryBase); + async onBootstrap() { + await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { + const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; + + this.logger.log('Verifying system mount folder checks'); + + // check each folder exists and is writable + for (const folder of Object.values(StorageFolder)) { + if (!flags.mountFiles) { + this.logger.log(`Writing initial mount file for the ${folder} folder`); + await this.verifyWriteAccess(folder); + } + + await this.verifyReadAccess(folder); + await this.verifyWriteAccess(folder); + } + + if (!flags.mountFiles) { + flags.mountFiles = true; + await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags); + this.logger.log('Successfully enabled system mount folders checks'); + } + + this.logger.log('Successfully verified system mount folder checks'); + }); } async handleDeleteFiles(job: IDeleteFilesJob) { @@ -38,4 +67,38 @@ export class StorageService { return JobStatus.SUCCESS; } + + private async verifyReadAccess(folder: StorageFolder) { + const { filePath } = this.getMountFilePaths(folder); + try { + await this.storageRepository.readFile(filePath); + } catch (error) { + this.logger.error(`Failed to read ${filePath}: ${error}`); + this.logger.error( + `The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`, + ); + throw new ImmichStartupError(`Failed to validate folder mount (read from "/${folder}")`); + } + } + + private async verifyWriteAccess(folder: StorageFolder) { + const { folderPath, filePath } = this.getMountFilePaths(folder); + try { + this.storageRepository.mkdirSync(folderPath); + await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`)); + } catch (error) { + this.logger.error(`Failed to write ${filePath}: ${error}`); + this.logger.error( + `The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`, + ); + throw new ImmichStartupError(`Failed to validate folder mount (write to "/${folder}")`); + } + } + + private getMountFilePaths(folder: StorageFolder) { + const folderPath = StorageCore.getBaseFolder(folder); + const filePath = join(folderPath, '.immich'); + + return { folderPath, filePath }; + } } diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index 2dd7e7fd5d208..064c9f75071ef 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -12,6 +12,9 @@ type Item = { label: string; }; +export class ImmichStartupError extends Error {} +export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError; + export const setupEventHandlers = (moduleRef: ModuleRef) => { const reflector = moduleRef.get(Reflector, { strict: false }); const repository = moduleRef.get(IEventRepository); diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 5857f587a0d2e..629c50c653003 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -9,6 +9,7 @@ import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { ApiService } from 'src/services/api.service'; +import { isStartUpError } from 'src/utils/events'; import { otelStart } from 'src/utils/instrumentation'; import { useSwagger } from 'src/utils/misc'; @@ -73,6 +74,9 @@ async function bootstrap() { } bootstrap().catch((error) => { - console.error(error); - throw 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 f920e8c9476ea..789b6f5287bbd 100644 --- a/server/src/workers/microservices.ts +++ b/server/src/workers/microservices.ts @@ -4,6 +4,7 @@ import { MicroservicesModule } from 'src/app.module'; import { envName, serverVersion } from 'src/constants'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; +import { isStartUpError } from 'src/utils/events'; import { otelStart } from 'src/utils/instrumentation'; export async function bootstrap() { @@ -25,7 +26,9 @@ export async function bootstrap() { if (!isMainThread) { bootstrap().catch((error) => { - console.error(error); - process.exit(1); + if (!isStartUpError(error)) { + console.error(error); + } + throw error; }); } diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 044a81b222d67..ad8801ff3f8bf 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -1,15 +1,17 @@ @@ -153,7 +145,7 @@ {/if} {#if shouldShowAccountInfoPanel} - + {/if} diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 6422983d94f8a..d398ca52a9d89 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,3 +1,5 @@ +import { AppRoute } from '$lib/constants'; +import { handleLogout } from '$lib/utils/auth'; import { createEventEmitter } from '$lib/utils/eventemitter'; import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; @@ -24,6 +26,7 @@ export interface Events { on_server_version: (serverVersion: ServerVersionResponseDto) => void; on_config_update: () => void; on_new_release: (newRelase: ReleaseEvent) => void; + on_session_delete: (sessionId: string) => void; } const websocket: Socket = io({ @@ -47,6 +50,7 @@ websocket .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) + .on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN)) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index d37f1bb96074d..0ac1658948fb6 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,7 +1,9 @@ import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { foldersStore } from '$lib/stores/folders.store'; import { purchaseStore } from '$lib/stores/purchase.store'; import { serverInfo } from '$lib/stores/server-info.store'; -import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; +import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { DateTime } from 'luxon'; @@ -87,3 +89,16 @@ export const getAccountAge = (): number => { return Number(accountAge); }; + +export const handleLogout = async (redirectUri: string) => { + try { + if (redirectUri.startsWith('/')) { + await goto(redirectUri); + } else { + window.location.href = redirectUri; + } + } finally { + resetSavedUser(); + foldersStore.clearCache(); + } +};