import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { serverVersion } from 'src/constants.js'; import { StorageCore } from 'src/cores/storage.core.js'; import { OnEvent } from 'src/decorators.js'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto.js'; import { ServerAboutResponseDto, ServerApkLinksDto, ServerConfigDto, ServerFeaturesDto, ServerMediaTypesResponseDto, ServerPingResponse, ServerStatsResponseDto, ServerStorageResponseDto, UsageByUserDto, } from 'src/dtos/server.dto.js'; import { StorageFolder, SystemMetadataKey } from 'src/enum.js'; import { UserStatsQueryResponse } from 'src/repositories/user.repository.js'; import { BaseService } from 'src/services/base.service.js'; import { asHumanReadable } from 'src/utils/bytes.js'; import { mimeTypes } from 'src/utils/mime-types.js'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isOcrEnabled, isSmartSearchEnabled, } from 'src/utils/misc.js'; @Injectable() export class ServerService extends BaseService { @OnEvent({ name: 'AppBootstrap' }) async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { await this.systemMetadataRepository.set(SystemMetadataKey.AdminOnboarding, { isOnboarded: true, }); } this.logger.log(`Feature Flags: ${JSON.stringify(await this.getFeatures(), null, 2)}`); } async getAboutInfo(): Promise { const version = `v${serverVersion.toString()}`; const { buildMetadata } = this.configRepository.getEnv(); const buildVersions = await this.serverInfoRepository.getBuildVersions(); const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.License); return { version, versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`, licensed: !!licensed, ...buildMetadata, ...buildVersions, }; } getApkLinks(): ServerApkLinksDto { const baseUrl = `https://github.com/immich-app/immich/releases/download/v${serverVersion.toString()}`; return { arm64v8a: `${baseUrl}/app-arm64-v8a-release.apk`, armeabiv7a: `${baseUrl}/app-armeabi-v7a-release.apk`, universal: `${baseUrl}/app-release.apk`, x86_64: `${baseUrl}/app-x86_64-release.apk`, }; } async getStorage(): Promise { const libraryBase = StorageCore.getBaseFolder(StorageFolder.Library); const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase); const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2); const serverInfo = new ServerStorageResponseDto(); serverInfo.diskAvailable = asHumanReadable(diskInfo.available); serverInfo.diskSize = asHumanReadable(diskInfo.total); serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free); serverInfo.diskAvailableRaw = diskInfo.available; serverInfo.diskSizeRaw = diskInfo.total; serverInfo.diskUseRaw = diskInfo.total - diskInfo.free; serverInfo.diskUsagePercentage = Number(usagePercentage); return serverInfo; } ping(): ServerPingResponse { return { res: 'pong' }; } async getFeatures(): Promise { const { reverseGeocoding, metadata, map, backup, machineLearning, trash, oauth, passwordLogin, notifications, ffmpeg, } = await this.getConfig({ withCache: false }); const { configFile } = this.configRepository.getEnv(); return { smartSearch: isSmartSearchEnabled(machineLearning), facialRecognition: isFacialRecognitionEnabled(machineLearning), duplicateDetection: isDuplicateDetectionEnabled(machineLearning), map: map.enabled, backups: !!backup.beta, reverseGeocoding: reverseGeocoding.enabled, importFaces: metadata.faces.import, sidecar: true, search: true, trash: trash.enabled, oauth: oauth.enabled, oauthAutoLaunch: oauth.autoLaunch, ocr: isOcrEnabled(machineLearning), passwordLogin: passwordLogin.enabled, configFile: !!configFile, email: notifications.smtp.enabled, realtimeTranscoding: ffmpeg.realtime.enabled, }; } async getSystemConfig(): Promise { const config = await this.getConfig({ withCache: false }); const isInitialized = !(await this.isSetupAvailable()); const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding); return { loginPageMessage: config.server.loginPageMessage, trashDays: config.trash.days, userDeleteDelay: config.user.deleteDelay, oauthButtonText: config.oauth.buttonText, oauthAccountManagementUrl: config.oauth.accountManagementUrl, isInitialized, isOnboarded: onboarding?.isOnboarded || false, externalDomain: config.server.externalDomain, publicUsers: config.server.publicUsers, mapDarkStyleUrl: config.map.darkStyle, mapLightStyleUrl: config.map.lightStyle, maintenanceMode: false, minFaces: config.machineLearning.facialRecognition.minFaces, }; } async getStatistics(): Promise { const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats(); const serverStats = new ServerStatsResponseDto(); serverStats.photos ??= 0; serverStats.videos ??= 0; serverStats.usage ??= 0; serverStats.usagePhotos ??= 0; serverStats.usageVideos ??= 0; serverStats.usageByUser ??= []; for (const user of userStats) { const usage = new UsageByUserDto(); usage.userId = user.userId; usage.userName = user.userName; usage.photos = user.photos; usage.videos = user.videos; usage.usage = user.usage; usage.usagePhotos = user.usagePhotos; usage.usageVideos = user.usageVideos; usage.quotaSizeInBytes = user.quotaSizeInBytes; serverStats.photos += usage.photos; serverStats.videos += usage.videos; serverStats.usage += usage.usage; serverStats.usagePhotos += usage.usagePhotos; serverStats.usageVideos += usage.usageVideos; serverStats.usageByUser.push(usage); } return serverStats; } getSupportedMediaTypes(): ServerMediaTypesResponseDto { return { video: Object.keys(mimeTypes.video), image: Object.keys(mimeTypes.image), sidecar: Object.keys(mimeTypes.sidecar), }; } async deleteLicense(): Promise { await this.systemMetadataRepository.delete(SystemMetadataKey.License); } async getLicense(): Promise { const license = await this.systemMetadataRepository.get(SystemMetadataKey.License); if (!license) { throw new NotFoundException(); } return license; } async setLicense(dto: LicenseKeyDto): Promise { if (!dto.licenseKey.startsWith('IMSV-')) { throw new BadRequestException('Invalid license key'); } const { licensePublicKey } = this.configRepository.getEnv(); const isLicenseValid = this.cryptoRepository.verifySha256( dto.licenseKey, dto.activationKey, licensePublicKey.server, ); if (!isLicenseValid) { throw new BadRequestException('Invalid license key'); } const licenseData = { ...dto, activatedAt: new Date() }; await this.systemMetadataRepository.set(SystemMetadataKey.License, licenseData); return licenseData; } }