feat(server): user and server license endpoints (#10682)
* feat: user license endpoints * feat: server license endpoints * chore: pr feedback * chore: add more test cases * chore: add prod license public keys * chore: open-api generation
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { ServerService } from 'src/services/server.service';
|
||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
@@ -18,6 +21,7 @@ describe(ServerService.name, () => {
|
||||
let serverInfoMock: Mocked<IServerInfoRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let cryptoMock: Mocked<ICryptoRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
storageMock = newStorageRepositoryMock();
|
||||
@@ -25,8 +29,9 @@ describe(ServerService.name, () => {
|
||||
serverInfoMock = newServerInfoRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
|
||||
sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock);
|
||||
sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -249,4 +254,33 @@ describe(ServerService.name, () => {
|
||||
expect(userMock.getUserStats).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLicense', () => {
|
||||
it('should save license if valid', async () => {
|
||||
systemMock.set.mockResolvedValue();
|
||||
|
||||
const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' };
|
||||
await sut.setLicense(license);
|
||||
|
||||
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not save license if invalid', async () => {
|
||||
userMock.upsertMetadata.mockResolvedValue();
|
||||
|
||||
const license = { licenseKey: 'license-key', activationKey: 'activation-key' };
|
||||
const call = sut.setLicense(license);
|
||||
await expect(call).rejects.toThrowError('Invalid license key');
|
||||
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLicense', () => {
|
||||
it('should delete license', async () => {
|
||||
userMock.upsertMetadata.mockResolvedValue();
|
||||
|
||||
await sut.deleteLicense();
|
||||
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { getBuildMetadata } from 'src/config';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import {
|
||||
ServerAboutResponseDto,
|
||||
ServerConfigDto,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
UsageByUserDto,
|
||||
} from 'src/dtos/server.dto';
|
||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { OnEvents } from 'src/interfaces/event.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
@@ -34,6 +36,7 @@ export class ServerService implements OnEvents {
|
||||
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
) {
|
||||
this.logger.setContext(ServerService.name);
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
@@ -53,10 +56,12 @@ export class ServerService implements OnEvents {
|
||||
const version = `v${serverVersion.toString()}`;
|
||||
const buildMetadata = getBuildMetadata();
|
||||
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,
|
||||
};
|
||||
@@ -154,4 +159,36 @@ export class ServerService implements OnEvents {
|
||||
sidecar: Object.keys(mimeTypes.sidecar),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteLicense(): Promise<void> {
|
||||
await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE);
|
||||
}
|
||||
|
||||
async getLicense(): Promise<LicenseKeyDto | null> {
|
||||
return this.systemMetadataRepository.get(SystemMetadataKey.LICENSE);
|
||||
}
|
||||
|
||||
async setLicense(dto: LicenseKeyDto): Promise<LicenseResponseDto> {
|
||||
if (!dto.licenseKey.startsWith('IMSV-')) {
|
||||
throw new BadRequestException('Invalid license key');
|
||||
}
|
||||
const licenseValid = this.cryptoRepository.verifySha256(
|
||||
dto.licenseKey,
|
||||
dto.activationKey,
|
||||
getServerLicensePublicKey(),
|
||||
);
|
||||
|
||||
if (!licenseValid) {
|
||||
throw new BadRequestException('Invalid license key');
|
||||
}
|
||||
|
||||
const licenseData = {
|
||||
...dto,
|
||||
activatedAt: new Date(),
|
||||
};
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.LICENSE, licenseData);
|
||||
|
||||
return licenseData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
@@ -285,6 +286,38 @@ describe(UserService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLicense', () => {
|
||||
it('should save license if valid', async () => {
|
||||
userMock.upsertMetadata.mockResolvedValue();
|
||||
|
||||
const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' };
|
||||
await sut.setLicense(authStub.user1, license);
|
||||
|
||||
expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, {
|
||||
key: UserMetadataKey.LICENSE,
|
||||
value: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('should not save license if invalid', async () => {
|
||||
userMock.upsertMetadata.mockResolvedValue();
|
||||
|
||||
const license = { licenseKey: 'license-key', activationKey: 'activation-key' };
|
||||
const call = sut.setLicense(authStub.admin, license);
|
||||
await expect(call).rejects.toThrowError('Invalid license key');
|
||||
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteLicense', () => {
|
||||
it('should delete license', async () => {
|
||||
userMock.upsertMetadata.mockResolvedValue();
|
||||
|
||||
await sut.deleteLicense(authStub.admin);
|
||||
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleUserSyncUsage', () => {
|
||||
it('should sync usage', async () => {
|
||||
await sut.handleUserSyncUsage();
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { getClientLicensePublicKey } from 'src/config';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { mapPreferences, UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
|
||||
import { mapUser, mapUserAdmin, UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
import { UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
@@ -123,6 +125,47 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
getLicense({ user }: AuthDto): LicenseResponseDto {
|
||||
const license = user.metadata.find(
|
||||
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
|
||||
);
|
||||
if (!license) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return license.value;
|
||||
}
|
||||
|
||||
async deleteLicense({ user }: AuthDto): Promise<void> {
|
||||
await this.userRepository.deleteMetadata(user.id, UserMetadataKey.LICENSE);
|
||||
}
|
||||
|
||||
async setLicense(auth: AuthDto, license: LicenseKeyDto): Promise<LicenseResponseDto> {
|
||||
if (!license.licenseKey.startsWith('IMCL-')) {
|
||||
throw new BadRequestException('Invalid license key');
|
||||
}
|
||||
const licenseValid = this.cryptoRepository.verifySha256(
|
||||
license.licenseKey,
|
||||
license.activationKey,
|
||||
getClientLicensePublicKey(),
|
||||
);
|
||||
|
||||
if (!licenseValid) {
|
||||
throw new BadRequestException('Invalid license key');
|
||||
}
|
||||
|
||||
const licenseData = {
|
||||
...license,
|
||||
activatedAt: new Date(),
|
||||
};
|
||||
|
||||
await this.userRepository.upsertMetadata(auth.user.id, {
|
||||
key: UserMetadataKey.LICENSE,
|
||||
value: licenseData,
|
||||
});
|
||||
|
||||
return licenseData;
|
||||
}
|
||||
|
||||
async handleUserSyncUsage(): Promise<JobStatus> {
|
||||
await this.userRepository.syncUsage();
|
||||
return JobStatus.SUCCESS;
|
||||
|
||||
Reference in New Issue
Block a user