mirror of
https://github.com/immich-app/immich.git
synced 2026-02-11 11:27:56 +03:00
Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf
This commit is contained in:
@@ -101,7 +101,7 @@ export class AlbumService {
|
||||
const album = await this._getAlbum({ authUser, albumId });
|
||||
|
||||
for (const sharedLink of album.sharedLinks) {
|
||||
await this.shareCore.remove(sharedLink.id, authUser.id);
|
||||
await this.shareCore.remove(authUser.id, sharedLink.id);
|
||||
}
|
||||
|
||||
await this._albumRepository.delete(album);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { SearchPropertiesDto } from './dto/search-properties.dto';
|
||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||
import { AssetEntity, AssetType } from '@app/infra';
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm/repository/Repository';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
@@ -19,15 +18,10 @@ import { IsNull, Not } from 'typeorm';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
|
||||
export interface IAssetRepository {
|
||||
create(
|
||||
createAssetDto: CreateAssetDto,
|
||||
ownerId: string,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
isVisible: boolean,
|
||||
checksum?: Buffer,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity>;
|
||||
get(id: string): Promise<AssetEntity | null>;
|
||||
create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity>;
|
||||
remove(asset: AssetEntity): Promise<void>;
|
||||
|
||||
update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
|
||||
getAll(): Promise<AssetEntity[]>;
|
||||
getAllVideos(): Promise<AssetEntity[]>;
|
||||
@@ -282,44 +276,16 @@ export class AssetRepository implements IAssetRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new asset information in database
|
||||
* @param createAssetDto
|
||||
* @param ownerId
|
||||
* @param originalPath
|
||||
* @param mimeType
|
||||
* @returns Promise<AssetEntity>
|
||||
*/
|
||||
async create(
|
||||
createAssetDto: CreateAssetDto,
|
||||
ownerId: string,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
isVisible: boolean,
|
||||
checksum?: Buffer,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = new AssetEntity();
|
||||
asset.deviceAssetId = createAssetDto.deviceAssetId;
|
||||
asset.userId = ownerId;
|
||||
asset.deviceId = createAssetDto.deviceId;
|
||||
asset.type = !isVisible ? AssetType.VIDEO : createAssetDto.assetType || AssetType.OTHER; // If an asset is not visible, it is a LivePhotos video portion, therefore we can confidently assign the type as VIDEO here
|
||||
asset.originalPath = originalPath;
|
||||
asset.createdAt = createAssetDto.createdAt;
|
||||
asset.modifiedAt = createAssetDto.modifiedAt;
|
||||
asset.isFavorite = createAssetDto.isFavorite;
|
||||
asset.mimeType = mimeType;
|
||||
asset.duration = createAssetDto.duration || null;
|
||||
asset.checksum = checksum || null;
|
||||
asset.isVisible = isVisible;
|
||||
asset.livePhotoVideoId = livePhotoAssetEntity ? livePhotoAssetEntity.id : null;
|
||||
get(id: string): Promise<AssetEntity | null> {
|
||||
return this.assetRepository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
const createdAsset = await this.assetRepository.save(asset);
|
||||
async create(asset: Omit<AssetEntity, 'id'>): Promise<AssetEntity> {
|
||||
return this.assetRepository.save(asset);
|
||||
}
|
||||
|
||||
if (!createdAsset) {
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
return createdAsset;
|
||||
async remove(asset: AssetEntity): Promise<void> {
|
||||
await this.assetRepository.remove(asset);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,11 +19,9 @@ import {
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { ServeFileDto } from './dto/serve-file.dto';
|
||||
import { Response as Res } from 'express';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
|
||||
import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
@@ -33,9 +31,9 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
|
||||
import { AssetResponseDto } from '@app/domain';
|
||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||
import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||
import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
|
||||
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
|
||||
import { AssetCountByTimeBucketResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
@@ -55,12 +53,13 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { SharedLinkResponseDto } from '@app/domain';
|
||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Asset')
|
||||
@Controller('asset')
|
||||
export class AssetController {
|
||||
constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {}
|
||||
constructor(private assetService: AssetService) {}
|
||||
|
||||
@Authenticated({ isShared: true })
|
||||
@Post('upload')
|
||||
@@ -81,13 +80,22 @@ export class AssetController {
|
||||
async uploadFile(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
|
||||
@Body(ValidationPipe) createAssetDto: CreateAssetDto,
|
||||
@Body(ValidationPipe) dto: CreateAssetDto,
|
||||
@Response({ passthrough: true }) res: Res,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
const originalAssetData = files.assetData[0];
|
||||
const livePhotoAssetData = files.livePhotoData?.[0];
|
||||
const file = mapToUploadFile(files.assetData[0]);
|
||||
const _livePhotoFile = files.livePhotoData?.[0];
|
||||
let livePhotoFile;
|
||||
if (_livePhotoFile) {
|
||||
livePhotoFile = mapToUploadFile(_livePhotoFile);
|
||||
}
|
||||
|
||||
return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData);
|
||||
const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile);
|
||||
if (responseDto.duplicate) {
|
||||
res.send(200);
|
||||
}
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Authenticated({ isShared: true })
|
||||
@@ -276,37 +284,10 @@ export class AssetController {
|
||||
@Delete('/')
|
||||
async deleteAsset(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Body(ValidationPipe) assetIds: DeleteAssetDto,
|
||||
@Body(ValidationPipe) dto: DeleteAssetDto,
|
||||
): Promise<DeleteAssetResponseDto[]> {
|
||||
await this.assetService.checkAssetsAccess(authUser, assetIds.ids, true);
|
||||
|
||||
const deleteAssetList: AssetResponseDto[] = [];
|
||||
|
||||
for (const id of assetIds.ids) {
|
||||
const assets = await this.assetService.getAssetById(authUser, id);
|
||||
if (!assets) {
|
||||
continue;
|
||||
}
|
||||
deleteAssetList.push(assets);
|
||||
|
||||
if (assets.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
|
||||
if (livePhotoVideo) {
|
||||
deleteAssetList.push(livePhotoVideo);
|
||||
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.assetService.deleteAssetById(assetIds);
|
||||
|
||||
result.forEach((res) => {
|
||||
deleteAssetList.filter((a) => a.id == res.id && res.status == DeleteAssetStatusEnum.SUCCESS);
|
||||
});
|
||||
|
||||
await this.backgroundTaskService.deleteFileOnDisk(deleteAssetList as any[]);
|
||||
|
||||
return result;
|
||||
await this.assetService.checkAssetsAccess(authUser, dto.ids, true);
|
||||
return this.assetService.deleteAll(authUser, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
52
server/apps/immich/src/api-v1/asset/asset.core.ts
Normal file
52
server/apps/immich/src/api-v1/asset/asset.core.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { timeUtils } from '@app/common';
|
||||
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
|
||||
import { AssetEntity } from '@app/infra/db/entities';
|
||||
import { StorageService } from '@app/storage';
|
||||
import { IAssetRepository } from './asset-repository';
|
||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||
|
||||
export class AssetCore {
|
||||
constructor(
|
||||
private repository: IAssetRepository,
|
||||
private jobRepository: IJobRepository,
|
||||
private storageService: StorageService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
authUser: AuthUserDto,
|
||||
dto: CreateAssetDto,
|
||||
file: UploadFile,
|
||||
livePhotoAssetId?: string,
|
||||
): Promise<AssetEntity> {
|
||||
let asset = await this.repository.create({
|
||||
userId: authUser.id,
|
||||
|
||||
mimeType: file.mimeType,
|
||||
checksum: file.checksum || null,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
createdAt: timeUtils.checkValidTimestamp(dto.createdAt) ? dto.createdAt : new Date().toISOString(),
|
||||
modifiedAt: timeUtils.checkValidTimestamp(dto.modifiedAt) ? dto.modifiedAt : new Date().toISOString(),
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
|
||||
type: dto.assetType,
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
isVisible: dto.isVisible ?? true,
|
||||
livePhotoVideoId: livePhotoAssetId || null,
|
||||
resizePath: null,
|
||||
webpPath: null,
|
||||
encodedVideoPath: null,
|
||||
tags: [],
|
||||
sharedLinks: [],
|
||||
});
|
||||
|
||||
asset = await this.storageService.moveAsset(asset, file.originalName);
|
||||
|
||||
await this.jobRepository.add({ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import { AssetService } from './asset.service';
|
||||
import { AssetController } from './asset.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from '@app/infra';
|
||||
import { BackgroundTaskModule } from '../../modules/background-task/background-task.module';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { CommunicationModule } from '../communication/communication.module';
|
||||
import { AssetRepository, IAssetRepository } from './asset-repository';
|
||||
import { DownloadModule } from '../../modules/download/download.module';
|
||||
@@ -21,14 +19,13 @@ const ASSET_REPOSITORY_PROVIDER = {
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AssetEntity]),
|
||||
CommunicationModule,
|
||||
BackgroundTaskModule,
|
||||
DownloadModule,
|
||||
TagModule,
|
||||
StorageModule,
|
||||
forwardRef(() => AlbumModule),
|
||||
],
|
||||
controllers: [AssetController],
|
||||
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],
|
||||
providers: [AssetService, ASSET_REPOSITORY_PROVIDER],
|
||||
exports: [ASSET_REPOSITORY_PROVIDER],
|
||||
})
|
||||
export class AssetModule {}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { IAssetRepository } from './asset-repository';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { AssetService } from './asset.service';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { AssetEntity, AssetType } from '@app/infra';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { AssetCountByTimeBucket } from './response-dto/asset-count-by-time-group-response.dto';
|
||||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
|
||||
import { StorageService } from '@app/storage';
|
||||
import { ICryptoRepository, IJobRepository, ISharedLinkRepository } from '@app/domain';
|
||||
import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain';
|
||||
import {
|
||||
authStub,
|
||||
newCryptoRepositoryMock,
|
||||
@@ -23,105 +21,102 @@ import {
|
||||
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
createAssetDto.deviceId = 'deviceId';
|
||||
createAssetDto.assetType = AssetType.OTHER;
|
||||
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.isFavorite = false;
|
||||
createAssetDto.duration = '0:00:00.000000';
|
||||
|
||||
return createAssetDto;
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.userId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.resizePath = '';
|
||||
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.mimeType = 'image/jpeg';
|
||||
asset_1.webpPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
return asset_1;
|
||||
};
|
||||
|
||||
const _getAsset_2 = () => {
|
||||
const asset_2 = new AssetEntity();
|
||||
|
||||
asset_2.id = 'id_2';
|
||||
asset_2.userId = 'user_id_1';
|
||||
asset_2.deviceAssetId = 'device_asset_id_2';
|
||||
asset_2.deviceId = 'device_id_1';
|
||||
asset_2.type = AssetType.VIDEO;
|
||||
asset_2.originalPath = 'fake_path/asset_2.jpeg';
|
||||
asset_2.resizePath = '';
|
||||
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.isFavorite = false;
|
||||
asset_2.mimeType = 'image/jpeg';
|
||||
asset_2.webpPath = '';
|
||||
asset_2.encodedVideoPath = '';
|
||||
asset_2.duration = '0:00:00.000000';
|
||||
|
||||
return asset_2;
|
||||
};
|
||||
|
||||
const _getAssets = () => {
|
||||
return [_getAsset_1(), _getAsset_2()];
|
||||
};
|
||||
|
||||
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
|
||||
const result1 = new AssetCountByTimeBucket();
|
||||
result1.count = 2;
|
||||
result1.timeBucket = '2022-06-01T00:00:00.000Z';
|
||||
|
||||
const result2 = new AssetCountByTimeBucket();
|
||||
result1.count = 5;
|
||||
result1.timeBucket = '2022-07-01T00:00:00.000Z';
|
||||
|
||||
return [result1, result2];
|
||||
};
|
||||
|
||||
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
const result = new AssetCountByUserIdResponseDto();
|
||||
|
||||
result.videos = 2;
|
||||
result.photos = 2;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('AssetService', () => {
|
||||
let sui: AssetService;
|
||||
let sut: AssetService;
|
||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
|
||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
|
||||
let storageSeriveMock: jest.Mocked<StorageService>;
|
||||
let storageServiceMock: jest.Mocked<StorageService>;
|
||||
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
const authUser: AuthUserDto = Object.freeze({
|
||||
id: 'user_id_1',
|
||||
email: 'auth@test.com',
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
createAssetDto.deviceId = 'deviceId';
|
||||
createAssetDto.assetType = AssetType.OTHER;
|
||||
createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
createAssetDto.isFavorite = false;
|
||||
createAssetDto.duration = '0:00:00.000000';
|
||||
|
||||
return createAssetDto;
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.userId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.resizePath = '';
|
||||
asset_1.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.mimeType = 'image/jpeg';
|
||||
asset_1.webpPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
return asset_1;
|
||||
};
|
||||
|
||||
const _getAsset_2 = () => {
|
||||
const asset_2 = new AssetEntity();
|
||||
|
||||
asset_2.id = 'id_2';
|
||||
asset_2.userId = 'user_id_1';
|
||||
asset_2.deviceAssetId = 'device_asset_id_2';
|
||||
asset_2.deviceId = 'device_id_1';
|
||||
asset_2.type = AssetType.VIDEO;
|
||||
asset_2.originalPath = 'fake_path/asset_2.jpeg';
|
||||
asset_2.resizePath = '';
|
||||
asset_2.createdAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.modifiedAt = '2022-06-19T23:41:36.910Z';
|
||||
asset_2.isFavorite = false;
|
||||
asset_2.mimeType = 'image/jpeg';
|
||||
asset_2.webpPath = '';
|
||||
asset_2.encodedVideoPath = '';
|
||||
asset_2.duration = '0:00:00.000000';
|
||||
|
||||
return asset_2;
|
||||
};
|
||||
|
||||
const _getAssets = () => {
|
||||
return [_getAsset_1(), _getAsset_2()];
|
||||
};
|
||||
|
||||
const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
|
||||
const result1 = new AssetCountByTimeBucket();
|
||||
result1.count = 2;
|
||||
result1.timeBucket = '2022-06-01T00:00:00.000Z';
|
||||
|
||||
const result2 = new AssetCountByTimeBucket();
|
||||
result1.count = 5;
|
||||
result1.timeBucket = '2022-07-01T00:00:00.000Z';
|
||||
|
||||
return [result1, result2];
|
||||
};
|
||||
|
||||
const _getAssetCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
const result = new AssetCountByUserIdResponseDto();
|
||||
|
||||
result.videos = 2;
|
||||
result.photos = 2;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
assetRepositoryMock = {
|
||||
get: jest.fn(),
|
||||
create: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
|
||||
update: jest.fn(),
|
||||
getAll: jest.fn(),
|
||||
getAllVideos: jest.fn(),
|
||||
@@ -151,18 +146,21 @@ describe('AssetService', () => {
|
||||
downloadArchive: jest.fn(),
|
||||
};
|
||||
|
||||
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
|
||||
storageServiceMock = {
|
||||
moveAsset: jest.fn(),
|
||||
removeEmptyDirectories: jest.fn(),
|
||||
} as unknown as jest.Mocked<StorageService>;
|
||||
|
||||
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
|
||||
sui = new AssetService(
|
||||
sut = new AssetService(
|
||||
assetRepositoryMock,
|
||||
albumRepositoryMock,
|
||||
a,
|
||||
backgroundTaskServiceMock,
|
||||
downloadServiceMock as DownloadService,
|
||||
storageSeriveMock,
|
||||
storageServiceMock,
|
||||
sharedLinkRepositoryMock,
|
||||
jobMock,
|
||||
cryptoMock,
|
||||
@@ -178,7 +176,7 @@ describe('AssetService', () => {
|
||||
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
|
||||
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await expect(sui.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
|
||||
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
|
||||
@@ -196,7 +194,7 @@ describe('AssetService', () => {
|
||||
sharedLinkRepositoryMock.get.mockResolvedValue(null);
|
||||
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
|
||||
|
||||
await expect(sui.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
@@ -215,7 +213,7 @@ describe('AssetService', () => {
|
||||
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
|
||||
sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
|
||||
|
||||
await expect(sui.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
await expect(sut.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
|
||||
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
|
||||
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
@@ -223,27 +221,94 @@ describe('AssetService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Currently failing due to calculate checksum from a file
|
||||
it('create an asset', async () => {
|
||||
const assetEntity = _getAsset_1();
|
||||
describe('uploadFile', () => {
|
||||
it('should handle a file upload', async () => {
|
||||
const assetEntity = _getAsset_1();
|
||||
const file = {
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
const dto = _getCreateAssetDto();
|
||||
|
||||
assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
|
||||
assetRepositoryMock.create.mockImplementation(() => Promise.resolve(assetEntity));
|
||||
storageServiceMock.moveAsset.mockResolvedValue({ ...assetEntity, originalPath: 'fake_new_path/asset_123.jpeg' });
|
||||
|
||||
const originalPath = 'fake_path/asset_1.jpeg';
|
||||
const mimeType = 'image/jpeg';
|
||||
const createAssetDto = _getCreateAssetDto();
|
||||
const result = await sui.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
originalPath,
|
||||
mimeType,
|
||||
Buffer.from('0x5041E6328F7DF8AFF650BEDAED9251897D9A6241', 'hex'),
|
||||
true,
|
||||
);
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
|
||||
});
|
||||
|
||||
expect(result.userId).toEqual(authUser.id);
|
||||
expect(result.resizePath).toEqual('');
|
||||
expect(result.webpPath).toEqual('');
|
||||
it('should handle a duplicate', async () => {
|
||||
const file = {
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
const dto = _getCreateAssetDto();
|
||||
const error = new QueryFailedError('', [], '');
|
||||
(error as any).constraint = 'UQ_userid_checksum';
|
||||
|
||||
assetRepositoryMock.create.mockRejectedValue(error);
|
||||
assetRepositoryMock.getAssetByChecksum.mockResolvedValue(_getAsset_1());
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
|
||||
|
||||
expect(jobMock.add).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILE_ON_DISK,
|
||||
data: { assets: [{ originalPath: 'fake_path/asset_1.jpeg', resizePath: null }] },
|
||||
});
|
||||
expect(storageServiceMock.moveAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a live photo', async () => {
|
||||
const file = {
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
const asset = {
|
||||
id: 'live-photo-asset',
|
||||
originalPath: file.originalPath,
|
||||
userId: authStub.user1.id,
|
||||
type: AssetType.IMAGE,
|
||||
isVisible: true,
|
||||
} as AssetEntity;
|
||||
|
||||
const livePhotoFile = {
|
||||
originalPath: 'fake_path/asset_1.mp4',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('live photo file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
};
|
||||
|
||||
const livePhotoAsset = {
|
||||
id: 'live-photo-motion',
|
||||
originalPath: livePhotoFile.originalPath,
|
||||
userId: authStub.user1.id,
|
||||
type: AssetType.VIDEO,
|
||||
isVisible: false,
|
||||
} as AssetEntity;
|
||||
|
||||
const dto = _getCreateAssetDto();
|
||||
const error = new QueryFailedError('', [], '');
|
||||
(error as any).constraint = 'UQ_userid_checksum';
|
||||
|
||||
assetRepositoryMock.create.mockResolvedValueOnce(livePhotoAsset);
|
||||
assetRepositoryMock.create.mockResolvedValueOnce(asset);
|
||||
storageServiceMock.moveAsset.mockImplementation((asset) => Promise.resolve(asset));
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file, livePhotoFile)).resolves.toEqual({
|
||||
duplicate: false,
|
||||
id: 'live-photo-asset',
|
||||
});
|
||||
|
||||
expect(jobMock.add.mock.calls).toEqual([
|
||||
[{ name: JobName.ASSET_UPLOADED, data: { asset: livePhotoAsset, fileName: file.originalName } }],
|
||||
[{ name: JobName.ASSET_UPLOADED, data: { asset, fileName: file.originalName } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('get assets by device id', async () => {
|
||||
@@ -254,7 +319,7 @@ describe('AssetService', () => {
|
||||
);
|
||||
|
||||
const deviceId = 'device_id_1';
|
||||
const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
|
||||
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
|
||||
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
|
||||
@@ -267,7 +332,7 @@ describe('AssetService', () => {
|
||||
Promise.resolve<AssetCountByTimeBucket[]>(assetCountByTimeBucket),
|
||||
);
|
||||
|
||||
const result = await sui.getAssetCountByTimeBucket(authUser, {
|
||||
const result = await sut.getAssetCountByTimeBucket(authStub.user1, {
|
||||
timeGroup: TimeGroupEnum.Month,
|
||||
});
|
||||
|
||||
@@ -282,18 +347,70 @@ describe('AssetService', () => {
|
||||
Promise.resolve<AssetCountByUserIdResponseDto>(assetCount),
|
||||
);
|
||||
|
||||
const result = await sui.getAssetCountByUserId(authUser);
|
||||
const result = await sut.getAssetCountByUserId(authStub.user1);
|
||||
|
||||
expect(result).toEqual(assetCount);
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should return failed status when an asset is missing', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'FAILED' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return failed status a delete fails', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
|
||||
assetRepositoryMock.remove.mockRejectedValue('delete failed');
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'FAILED' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete a live photo', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValueOnce({ id: 'asset1', livePhotoVideoId: 'live-photo' } as AssetEntity);
|
||||
assetRepositoryMock.get.mockResolvedValueOnce({ id: 'live-photo' } as AssetEntity);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'SUCCESS' },
|
||||
{ id: 'live-photo', status: 'SUCCESS' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILE_ON_DISK,
|
||||
data: { assets: [{ id: 'asset1', livePhotoVideoId: 'live-photo' }, { id: 'live-photo' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete a batch of assets', async () => {
|
||||
assetRepositoryMock.get.mockImplementation((id) => Promise.resolve({ id } as AssetEntity));
|
||||
assetRepositoryMock.remove.mockImplementation(() => Promise.resolve());
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'SUCCESS' },
|
||||
{ id: 'asset2', status: 'SUCCESS' },
|
||||
]);
|
||||
|
||||
expect(jobMock.add.mock.calls).toEqual([
|
||||
[{ name: JobName.DELETE_FILE_ON_DISK, data: { assets: [{ id: 'asset1' }, { id: 'asset2' }] } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkDownloadAccess', () => {
|
||||
it('should validate download access', async () => {
|
||||
await sui.checkDownloadAccess(authStub.adminSharedLink);
|
||||
await sut.checkDownloadAccess(authStub.adminSharedLink);
|
||||
});
|
||||
|
||||
it('should not allow when user is not allowed to download', async () => {
|
||||
expect(() => sui.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
|
||||
expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ import { SearchAssetDto } from './dto/search-asset.dto';
|
||||
import fs from 'fs/promises';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '@app/domain';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { AssetResponseDto, JobName, mapAsset, mapAssetWithoutExif } from '@app/domain';
|
||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
|
||||
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
|
||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||
@@ -37,13 +37,12 @@ import {
|
||||
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
|
||||
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
|
||||
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
|
||||
import { timeUtils } from '@app/common/utils';
|
||||
import { AssetCore } from './asset.core';
|
||||
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
|
||||
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
|
||||
import { ICryptoRepository, IJobRepository, JobName } from '@app/domain';
|
||||
import { ICryptoRepository, IJobRepository } from '@app/domain';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { DownloadDto } from './dto/download-library.dto';
|
||||
import { IAlbumRepository } from '../album/album-repository';
|
||||
@@ -55,7 +54,6 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
|
||||
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
|
||||
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { ImmichFile } from '../../config/asset-upload.config';
|
||||
|
||||
const fileInfo = promisify(stat);
|
||||
|
||||
@@ -63,142 +61,69 @@ const fileInfo = promisify(stat);
|
||||
export class AssetService {
|
||||
readonly logger = new Logger(AssetService.name);
|
||||
private shareCore: ShareCore;
|
||||
private assetCore: AssetCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
|
||||
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
|
||||
@InjectRepository(AssetEntity)
|
||||
private assetRepository: Repository<AssetEntity>,
|
||||
private backgroundTaskService: BackgroundTaskService,
|
||||
private downloadService: DownloadService,
|
||||
private storageService: StorageService,
|
||||
storageService: StorageService,
|
||||
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
) {
|
||||
this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService);
|
||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
public async handleUploadedAsset(
|
||||
public async uploadFile(
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
res: Res,
|
||||
originalAssetData: ImmichFile,
|
||||
livePhotoAssetData?: ImmichFile,
|
||||
) {
|
||||
const checksum = originalAssetData.checksum;
|
||||
const isLivePhoto = livePhotoAssetData !== undefined;
|
||||
let livePhotoAssetEntity: AssetEntity | undefined;
|
||||
dto: CreateAssetDto,
|
||||
file: UploadFile,
|
||||
livePhotoFile?: UploadFile,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
if (livePhotoFile) {
|
||||
livePhotoFile.originalName = file.originalName;
|
||||
}
|
||||
|
||||
let livePhotoAsset: AssetEntity | null = null;
|
||||
|
||||
try {
|
||||
if (isLivePhoto) {
|
||||
const livePhotoChecksum = livePhotoAssetData.checksum;
|
||||
livePhotoAssetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
livePhotoAssetData.path,
|
||||
livePhotoAssetData.mimetype,
|
||||
livePhotoChecksum,
|
||||
false,
|
||||
);
|
||||
|
||||
if (!livePhotoAssetEntity) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: livePhotoAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
|
||||
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
|
||||
|
||||
await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset: livePhotoAssetEntity } });
|
||||
if (livePhotoFile) {
|
||||
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
|
||||
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
|
||||
}
|
||||
|
||||
const assetEntity = await this.createUserAsset(
|
||||
authUser,
|
||||
createAssetDto,
|
||||
originalAssetData.path,
|
||||
originalAssetData.mimetype,
|
||||
checksum,
|
||||
true,
|
||||
livePhotoAssetEntity,
|
||||
);
|
||||
|
||||
if (!assetEntity) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: originalAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
throw new BadRequestException('Asset not created');
|
||||
}
|
||||
|
||||
const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname);
|
||||
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id);
|
||||
|
||||
return { id: asset.id, duplicate: false };
|
||||
} catch (error: any) {
|
||||
// clean up files
|
||||
await this.jobRepository.add({
|
||||
name: JobName.ASSET_UPLOADED,
|
||||
data: { asset: movedAsset, fileName: originalAssetData.originalname },
|
||||
name: JobName.DELETE_FILE_ON_DISK,
|
||||
data: {
|
||||
assets: [
|
||||
{
|
||||
originalPath: file.originalPath,
|
||||
resizePath: livePhotoFile?.originalPath || null,
|
||||
} as AssetEntity,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return new AssetFileUploadResponseDto(movedAsset.id);
|
||||
} catch (err) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: originalAssetData.path,
|
||||
} as any,
|
||||
]); // simulate asset to make use of delete queue (or use fs.unlink instead)
|
||||
|
||||
if (isLivePhoto) {
|
||||
await this.backgroundTaskService.deleteFileOnDisk([
|
||||
{
|
||||
originalPath: livePhotoAssetData.path,
|
||||
} as any,
|
||||
]);
|
||||
// handle duplicates with a success response
|
||||
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
|
||||
const duplicate = await this.getAssetByChecksum(authUser.id, file.checksum);
|
||||
return { id: duplicate.id, duplicate: true };
|
||||
}
|
||||
|
||||
if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
|
||||
const existedAsset = await this.getAssetByChecksum(authUser.id, checksum);
|
||||
res.status(200); // normal POST is 201. we use 200 to indicate the asset already exists
|
||||
return new AssetFileUploadResponseDto(existedAsset.id);
|
||||
}
|
||||
|
||||
Logger.error(`Error uploading file ${err}`);
|
||||
throw new BadRequestException(`Error uploading file`, `${err}`);
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw new BadRequestException(`Error uploading file`, `${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async createUserAsset(
|
||||
authUser: AuthUserDto,
|
||||
createAssetDto: CreateAssetDto,
|
||||
originalPath: string,
|
||||
mimeType: string,
|
||||
checksum: Buffer,
|
||||
isVisible: boolean,
|
||||
livePhotoAssetEntity?: AssetEntity,
|
||||
): Promise<AssetEntity> {
|
||||
if (!timeUtils.checkValidTimestamp(createAssetDto.createdAt)) {
|
||||
createAssetDto.createdAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
if (!timeUtils.checkValidTimestamp(createAssetDto.modifiedAt)) {
|
||||
createAssetDto.modifiedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const assetEntity = await this._assetRepository.create(
|
||||
createAssetDto,
|
||||
authUser.id,
|
||||
originalPath,
|
||||
mimeType,
|
||||
isVisible,
|
||||
checksum,
|
||||
livePhotoAssetEntity,
|
||||
);
|
||||
|
||||
return assetEntity;
|
||||
}
|
||||
|
||||
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
|
||||
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
|
||||
}
|
||||
@@ -520,26 +445,35 @@ export class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteAssetById(assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
|
||||
public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
|
||||
const deleteQueue: AssetEntity[] = [];
|
||||
const result: DeleteAssetResponseDto[] = [];
|
||||
|
||||
const target = assetIds.ids;
|
||||
for (const assetId of target) {
|
||||
const res = await this.assetRepository.delete({
|
||||
id: assetId,
|
||||
});
|
||||
|
||||
if (res.affected) {
|
||||
result.push({
|
||||
id: assetId,
|
||||
status: DeleteAssetStatusEnum.SUCCESS,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
id: assetId,
|
||||
status: DeleteAssetStatusEnum.FAILED,
|
||||
});
|
||||
const ids = dto.ids.slice();
|
||||
for (const id of ids) {
|
||||
const asset = await this._assetRepository.get(id);
|
||||
if (!asset) {
|
||||
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this._assetRepository.remove(asset);
|
||||
|
||||
result.push({ id: asset.id, status: DeleteAssetStatusEnum.SUCCESS });
|
||||
deleteQueue.push(asset as any);
|
||||
|
||||
// TODO refactor this to use cascades
|
||||
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
|
||||
ids.push(asset.livePhotoVideoId);
|
||||
}
|
||||
} catch {
|
||||
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteQueue.length > 0) {
|
||||
await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets: deleteQueue } });
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { AssetType } from '@app/infra';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { ImmichFile } from '../../../config/asset-upload.config';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@IsNotEmpty()
|
||||
@@ -22,9 +23,29 @@ export class CreateAssetDto {
|
||||
@IsNotEmpty()
|
||||
isFavorite!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isVisible?: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
fileExtension!: string;
|
||||
|
||||
@IsOptional()
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface UploadFile {
|
||||
mimeType: string;
|
||||
checksum: Buffer;
|
||||
originalPath: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
export function mapToUploadFile(file: ImmichFile): UploadFile {
|
||||
return {
|
||||
checksum: file.checksum,
|
||||
mimeType: file.mimetype,
|
||||
originalPath: file.path,
|
||||
originalName: file.originalname,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
export class AssetFileUploadResponseDto {
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
id: string;
|
||||
id!: string;
|
||||
duplicate!: boolean;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
this.logger.log(`New websocket connection: ${client.id}`);
|
||||
const user = await this.authService.validate(client.request.headers);
|
||||
const user = await this.authService.validate(client.request.headers, {});
|
||||
if (user) {
|
||||
client.join(user.id);
|
||||
} else {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../../decorators/authenticated.decorator';
|
||||
import { DeviceInfoService } from './device-info.service';
|
||||
import { UpsertDeviceInfoDto } from './dto/upsert-device-info.dto';
|
||||
import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/device-info-response.dto';
|
||||
|
||||
@Authenticated()
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Device Info')
|
||||
@Controller('device-info')
|
||||
export class DeviceInfoController {
|
||||
constructor(private readonly deviceInfoService: DeviceInfoService) {}
|
||||
|
||||
@Put()
|
||||
public async upsertDeviceInfo(
|
||||
@GetAuthUser() user: AuthUserDto,
|
||||
@Body(ValidationPipe) dto: UpsertDeviceInfoDto,
|
||||
): Promise<DeviceInfoResponseDto> {
|
||||
const deviceInfo = await this.deviceInfoService.upsert({ ...dto, userId: user.id });
|
||||
return mapDeviceInfoResponse(deviceInfo);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DeviceInfoService } from './device-info.service';
|
||||
import { DeviceInfoController } from './device-info.controller';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DeviceInfoEntity } from '@app/infra';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DeviceInfoEntity])],
|
||||
controllers: [DeviceInfoController],
|
||||
providers: [DeviceInfoService],
|
||||
})
|
||||
export class DeviceInfoModule {}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { DeviceInfoEntity } from '@app/infra';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
type EntityKeys = Pick<DeviceInfoEntity, 'deviceId' | 'userId'>;
|
||||
type Entity = EntityKeys & Partial<DeviceInfoEntity>;
|
||||
|
||||
@Injectable()
|
||||
export class DeviceInfoService {
|
||||
constructor(
|
||||
@InjectRepository(DeviceInfoEntity)
|
||||
private repository: Repository<DeviceInfoEntity>,
|
||||
) {}
|
||||
|
||||
public async upsert(entity: Entity): Promise<DeviceInfoEntity> {
|
||||
const { deviceId, userId } = entity;
|
||||
const exists = await this.repository.findOne({ where: { userId, deviceId } });
|
||||
|
||||
if (!exists) {
|
||||
if (!entity.isAutoBackup) {
|
||||
entity.isAutoBackup = false;
|
||||
}
|
||||
return await this.repository.save(entity);
|
||||
}
|
||||
|
||||
exists.isAutoBackup = entity.isAutoBackup ?? exists.isAutoBackup;
|
||||
exists.deviceType = entity.deviceType ?? exists.deviceType;
|
||||
return await this.repository.save(exists);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { immichAppConfig } from '@app/common/config';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AssetModule } from './api-v1/asset/asset.module';
|
||||
import { DeviceInfoModule } from './api-v1/device-info/device-info.module';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
|
||||
import { BackgroundTaskModule } from './modules/background-task/background-task.module';
|
||||
import { CommunicationModule } from './api-v1/communication/communication.module';
|
||||
import { AlbumModule } from './api-v1/album/album.module';
|
||||
import { AppController } from './app.controller';
|
||||
@@ -17,14 +15,14 @@ import { InfraModule } from '@app/infra';
|
||||
import {
|
||||
APIKeyController,
|
||||
AuthController,
|
||||
DeviceInfoController,
|
||||
OAuthController,
|
||||
ShareController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
} from './controllers';
|
||||
import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy';
|
||||
import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy';
|
||||
import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthGuard } from './middlewares/auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -36,12 +34,8 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
|
||||
|
||||
AssetModule,
|
||||
|
||||
DeviceInfoModule,
|
||||
|
||||
ServerInfoModule,
|
||||
|
||||
BackgroundTaskModule,
|
||||
|
||||
CommunicationModule,
|
||||
|
||||
AlbumModule,
|
||||
@@ -59,12 +53,13 @@ import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.str
|
||||
AppController,
|
||||
APIKeyController,
|
||||
AuthController,
|
||||
DeviceInfoController,
|
||||
OAuthController,
|
||||
ShareController,
|
||||
SystemConfigController,
|
||||
UserController,
|
||||
],
|
||||
providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy],
|
||||
providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
// TODO: check if consumer is needed or remove
|
||||
|
||||
23
server/apps/immich/src/controllers/device-info.controller.ts
Normal file
23
server/apps/immich/src/controllers/device-info.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
AuthUserDto,
|
||||
DeviceInfoResponseDto as ResponseDto,
|
||||
DeviceInfoService,
|
||||
UpsertDeviceInfoDto as UpsertDto,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Put, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
|
||||
@Authenticated()
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Device Info')
|
||||
@Controller('device-info')
|
||||
export class DeviceInfoController {
|
||||
constructor(private readonly service: DeviceInfoService) {}
|
||||
|
||||
@Put()
|
||||
upsertDeviceInfo(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) dto: UpsertDto): Promise<ResponseDto> {
|
||||
return this.service.upsert(authUser, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './api-key.controller';
|
||||
export * from './auth.controller';
|
||||
export * from './device-info.controller';
|
||||
export * from './oauth.controller';
|
||||
export * from './share.controller';
|
||||
export * from './system-config.controller';
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware';
|
||||
import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware';
|
||||
import { AuthGuard } from '../modules/immich-auth/guards/auth.guard';
|
||||
import { applyDecorators, SetMetadata } from '@nestjs/common';
|
||||
|
||||
interface AuthenticatedOptions {
|
||||
admin?: boolean;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export enum Metadata {
|
||||
AUTH_ROUTE = 'auth_route',
|
||||
ADMIN_ROUTE = 'admin_route',
|
||||
SHARED_ROUTE = 'shared_route',
|
||||
}
|
||||
|
||||
export const Authenticated = (options?: AuthenticatedOptions) => {
|
||||
const guards: Parameters<typeof UseGuards> = [AuthGuard];
|
||||
const decorators = [SetMetadata(Metadata.AUTH_ROUTE, true)];
|
||||
|
||||
options = options || {};
|
||||
|
||||
if (options.admin) {
|
||||
guards.push(AdminRolesGuard);
|
||||
decorators.push(SetMetadata(Metadata.ADMIN_ROUTE, true));
|
||||
}
|
||||
|
||||
if (!options.isShared) {
|
||||
guards.push(RouteNotSharedGuard);
|
||||
if (options.isShared) {
|
||||
decorators.push(SetMetadata(Metadata.SHARED_ROUTE, true));
|
||||
}
|
||||
|
||||
return UseGuards(...guards);
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { UserResponseDto } from '@app/domain';
|
||||
|
||||
interface UserRequest extends Request {
|
||||
user: UserResponseDto;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminRolesGuard implements CanActivate {
|
||||
logger = new Logger(AdminRolesGuard.name);
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<UserRequest>();
|
||||
const isAdmin = request.user?.isAdmin || false;
|
||||
if (!isAdmin) {
|
||||
this.logger.log(`Denied access to admin only route: ${request.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
46
server/apps/immich/src/middlewares/auth.guard.ts
Normal file
46
server/apps/immich/src/middlewares/auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { AuthService } from '@app/domain';
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { Metadata } from '../decorators/authenticated.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private logger = new Logger(AuthGuard.name);
|
||||
|
||||
constructor(private reflector: Reflector, private authService: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
|
||||
const isAuthRoute = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets);
|
||||
const isAdminRoute = this.reflector.getAllAndOverride(Metadata.ADMIN_ROUTE, targets);
|
||||
const isSharedRoute = this.reflector.getAllAndOverride(Metadata.SHARED_ROUTE, targets);
|
||||
|
||||
if (!isAuthRoute) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
const authDto = await this.authService.validate(req.headers, req.query as Record<string, string>);
|
||||
if (!authDto) {
|
||||
this.logger.warn(`Denied access to authenticated route: ${req.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authDto.isPublicUser && !isSharedRoute) {
|
||||
this.logger.warn(`Denied access to non-shared route: ${req.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAdminRoute && !authDto.isAdmin) {
|
||||
this.logger.warn(`Denied access to admin only route: ${req.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
req.user = authDto;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,20 @@ const redisHost = process.env.REDIS_HOSTNAME || 'immich_redis';
|
||||
const redisPort = parseInt(process.env.REDIS_PORT || '6379');
|
||||
const redisDb = parseInt(process.env.REDIS_DBINDEX || '0');
|
||||
const redisPassword = process.env.REDIS_PASSWORD || undefined;
|
||||
// const redisSocket = process.env.REDIS_SOCKET || undefined;
|
||||
const redisSocket = process.env.REDIS_SOCKET || undefined;
|
||||
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
private adapterConstructor: any;
|
||||
|
||||
async connectToRedis(): Promise<void> {
|
||||
const pubClient = createClient({
|
||||
url: `redis://${redisHost}:${redisPort}/${redisDb}`,
|
||||
password: redisPassword,
|
||||
database: redisDb,
|
||||
socket: {
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
path: redisSocket,
|
||||
},
|
||||
});
|
||||
const subClient = pubClient.duplicate();
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AuthUserDto } from '../decorators/auth-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RouteNotSharedGuard implements CanActivate {
|
||||
logger = new Logger(RouteNotSharedGuard.name);
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthUserDto;
|
||||
|
||||
// Inverse logic - I know it is weird
|
||||
if (user.isPublicUser) {
|
||||
this.logger.warn(`Denied attempt to access non-shared route: ${request.path}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BackgroundTaskProcessor } from './background-task.processor';
|
||||
import { BackgroundTaskService } from './background-task.service';
|
||||
|
||||
@Module({
|
||||
providers: [BackgroundTaskService, BackgroundTaskProcessor],
|
||||
exports: [BackgroundTaskService],
|
||||
})
|
||||
export class BackgroundTaskModule {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { IJobRepository, JobName } from '@app/domain';
|
||||
import { AssetEntity } from '@app/infra';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class BackgroundTaskService {
|
||||
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
|
||||
|
||||
async deleteFileOnDisk(assets: AssetEntity[]) {
|
||||
await this.jobRepository.add({ name: JobName.DELETE_FILE_ON_DISK, data: { assets } });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
|
||||
import { API_KEY_STRATEGY } from '../strategies/api-key.strategy';
|
||||
import { AUTH_COOKIE_STRATEGY } from '../strategies/user-auth.strategy';
|
||||
import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { APIKeyService, AuthUserDto } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
||||
|
||||
export const API_KEY_STRATEGY = 'api-key';
|
||||
|
||||
const options: IStrategyOptions = {
|
||||
header: 'x-api-key',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY) {
|
||||
constructor(private apiKeyService: APIKeyService) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
validate(token: string): Promise<AuthUserDto | null> {
|
||||
return this.apiKeyService.validate(token);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
|
||||
import { AuthUserDto, ShareService } from '@app/domain';
|
||||
|
||||
export const PUBLIC_SHARE_STRATEGY = 'public-share';
|
||||
|
||||
const options: IStrategyOptions = {
|
||||
header: 'x-immich-share-key',
|
||||
param: 'key',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE_STRATEGY) {
|
||||
constructor(private shareService: ShareService) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
validate(key: string): Promise<AuthUserDto | null> {
|
||||
return this.shareService.validate(key);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { AuthService, AuthUserDto } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import { Strategy } from 'passport-custom';
|
||||
|
||||
export const AUTH_COOKIE_STRATEGY = 'auth-cookie';
|
||||
|
||||
@Injectable()
|
||||
export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
validate(request: Request): Promise<AuthUserDto | null> {
|
||||
return this.authService.validate(request.headers);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { clearDb, getAuthUser, authCustom } from './test-utils';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { AlbumModule } from '../src/api-v1/album/album.module';
|
||||
import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { AuthService, DomainModule, UserService } from '@app/domain';
|
||||
import { AuthService, UserService } from '@app/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
@@ -20,9 +18,7 @@ describe('Album', () => {
|
||||
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
||||
}).compile();
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
database = app.get(DataSource);
|
||||
@@ -46,9 +42,7 @@ describe('Album', () => {
|
||||
let authService: AuthService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule],
|
||||
});
|
||||
const builder = Test.createTestingModule({ imports: [AppModule] });
|
||||
authUser = getAuthUser(); // set default auth user
|
||||
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { TestingModuleBuilder } from '@nestjs/testing';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthUserDto } from '../src/decorators/auth-user.decorator';
|
||||
import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard';
|
||||
import { AuthGuard } from '../src/middlewares/auth.guard';
|
||||
|
||||
type CustomAuthCallback = () => AuthUserDto;
|
||||
|
||||
@@ -34,5 +34,5 @@ export function authCustom(builder: TestingModuleBuilder, callback: CustomAuthCa
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return builder.overrideGuard(AuthGuard).useValue(canActivate);
|
||||
return builder.overrideProvider(AuthGuard).useValue(canActivate);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { clearDb, authCustom } from './test-utils';
|
||||
import { InfraModule } from '@app/infra';
|
||||
import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain';
|
||||
import { CreateUserDto, UserService, AuthUserDto } from '@app/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { UserController } from '../src/controllers';
|
||||
import { AuthService } from '@app/domain';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
@@ -24,10 +22,7 @@ describe('User', () => {
|
||||
|
||||
describe('without auth', () => {
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] }), AppModule],
|
||||
controllers: [UserController],
|
||||
}).compile();
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
database = app.get(DataSource);
|
||||
@@ -50,10 +45,7 @@ describe('User', () => {
|
||||
let authUser: AuthUserDto;
|
||||
|
||||
beforeAll(async () => {
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [DomainModule.register({ imports: [InfraModule] })],
|
||||
controllers: [UserController],
|
||||
});
|
||||
const builder = Test.createTestingModule({ imports: [AppModule] });
|
||||
const moduleFixture: TestingModule = await authCustom(builder, () => authUser).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { StorageMigrationProcessor } from './processors/storage-migration.proces
|
||||
import { ThumbnailGeneratorProcessor } from './processors/thumbnail.processor';
|
||||
import { UserDeletionProcessor } from './processors/user-deletion.processor';
|
||||
import { VideoTranscodeProcessor } from './processors/video-transcode.processor';
|
||||
import { BackgroundTaskProcessor } from './processors/background-task.processor';
|
||||
import { DomainModule } from '@app/domain';
|
||||
|
||||
@Module({
|
||||
@@ -37,6 +38,7 @@ import { DomainModule } from '@app/domain';
|
||||
MachineLearningProcessor,
|
||||
UserDeletionProcessor,
|
||||
StorageMigrationProcessor,
|
||||
BackgroundTaskProcessor,
|
||||
],
|
||||
})
|
||||
export class MicroservicesModule {}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { assetUtils } from '@app/common/utils';
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Job } from 'bull';
|
||||
import { JobName, QueueName } from '@app/domain';
|
||||
import { AssetEntity } from '@app/infra';
|
||||
import { AssetEntity } from '@app/infra/db/entities';
|
||||
|
||||
@Processor(QueueName.BACKGROUND_TASK)
|
||||
export class BackgroundTaskProcessor {
|
||||
@@ -235,6 +235,10 @@ export class MetadataExtractionProcessor {
|
||||
async extractVideoMetadata(job: Job<IVideoLengthExtractionProcessor>) {
|
||||
const { asset, fileName } = job.data;
|
||||
|
||||
if (!asset.isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await new Promise<ffmpeg.FfprobeData>((resolve, reject) =>
|
||||
ffmpeg.ffprobe(asset.originalPath, (err, data) => {
|
||||
|
||||
@@ -301,6 +301,43 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/device-info": {
|
||||
"put": {
|
||||
"operationId": "upsertDeviceInfo",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertDeviceInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeviceInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Device Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/oauth/mobile-redirect": {
|
||||
"get": {
|
||||
"operationId": "mobileRedirect",
|
||||
@@ -2505,43 +2542,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/device-info": {
|
||||
"put": {
|
||||
"operationId": "upsertDeviceInfo",
|
||||
"description": "",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertDeviceInfoDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeviceInfoResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Device Info"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/server-info": {
|
||||
"get": {
|
||||
"operationId": "getServerInfo",
|
||||
@@ -2993,6 +2993,63 @@
|
||||
"redirectUri"
|
||||
]
|
||||
},
|
||||
"DeviceTypeEnum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"IOS",
|
||||
"ANDROID",
|
||||
"WEB"
|
||||
]
|
||||
},
|
||||
"UpsertDeviceInfoDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"deviceType",
|
||||
"deviceId"
|
||||
]
|
||||
},
|
||||
"DeviceInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"deviceType",
|
||||
"userId",
|
||||
"deviceId",
|
||||
"createdAt",
|
||||
"isAutoBackup"
|
||||
]
|
||||
},
|
||||
"OAuthConfigDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3725,10 +3782,14 @@
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"duplicate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
"id",
|
||||
"duplicate"
|
||||
]
|
||||
},
|
||||
"DownloadFilesDto": {
|
||||
@@ -4261,63 +4322,6 @@
|
||||
"albumId"
|
||||
]
|
||||
},
|
||||
"DeviceTypeEnum": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"IOS",
|
||||
"ANDROID",
|
||||
"WEB"
|
||||
]
|
||||
},
|
||||
"UpsertDeviceInfoDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"deviceType",
|
||||
"deviceId"
|
||||
]
|
||||
},
|
||||
"DeviceInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deviceType": {
|
||||
"$ref": "#/components/schemas/DeviceTypeEnum"
|
||||
},
|
||||
"userId": {
|
||||
"type": "string"
|
||||
},
|
||||
"deviceId": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAutoBackup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"deviceType",
|
||||
"userId",
|
||||
"deviceId",
|
||||
"createdAt",
|
||||
"isAutoBackup"
|
||||
]
|
||||
},
|
||||
"ServerInfoResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
27
server/libs/domain/src/api-key/api-key.core.ts
Normal file
27
server/libs/domain/src/api-key/api-key.core.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
|
||||
@Injectable()
|
||||
export class APIKeyCore {
|
||||
constructor(private crypto: ICryptoRepository, private repository: IKeyRepository) {}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto | null> {
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
const user = keyEntity.user;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid API key');
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,9 @@
|
||||
import { APIKeyEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { authStub, keyStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyService } from './api-key.service';
|
||||
|
||||
const adminKey = Object.freeze({
|
||||
id: 1,
|
||||
name: 'My Key',
|
||||
key: 'my-api-key (hashed)',
|
||||
userId: authStub.admin.id,
|
||||
user: userEntityStub.admin,
|
||||
} as APIKeyEntity);
|
||||
|
||||
const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
describe(APIKeyService.name, () => {
|
||||
let sut: APIKeyService;
|
||||
let keyMock: jest.Mocked<IKeyRepository>;
|
||||
@@ -28,10 +17,8 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new key', async () => {
|
||||
keyMock.create.mockResolvedValue(adminKey);
|
||||
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
await sut.create(authStub.admin, { name: 'Test Key' });
|
||||
|
||||
expect(keyMock.create).toHaveBeenCalledWith({
|
||||
key: 'cmFuZG9tLWJ5dGVz (hashed)',
|
||||
name: 'Test Key',
|
||||
@@ -42,7 +29,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
keyMock.create.mockResolvedValue(adminKey);
|
||||
keyMock.create.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.create(authStub.admin, {});
|
||||
|
||||
@@ -66,7 +53,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should update a key', async () => {
|
||||
keyMock.getById.mockResolvedValue(adminKey);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.update(authStub.admin, 1, { name: 'New Name' });
|
||||
|
||||
@@ -84,7 +71,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete a key', async () => {
|
||||
keyMock.getById.mockResolvedValue(adminKey);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.delete(authStub.admin, 1);
|
||||
|
||||
@@ -102,7 +89,7 @@ describe(APIKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
keyMock.getById.mockResolvedValue(adminKey);
|
||||
keyMock.getById.mockResolvedValue(keyStub.admin);
|
||||
|
||||
await sut.getById(authStub.admin, 1);
|
||||
|
||||
@@ -112,29 +99,11 @@ describe(APIKeyService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all the keys for a user', async () => {
|
||||
keyMock.getByUserId.mockResolvedValue([adminKey]);
|
||||
keyMock.getByUserId.mockResolvedValue([keyStub.admin]);
|
||||
|
||||
await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1);
|
||||
|
||||
expect(keyMock.getByUserId).toHaveBeenCalledWith(authStub.admin.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
keyMock.getKey.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.validate(token)).resolves.toBeNull();
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
|
||||
it('should validate the token', async () => {
|
||||
keyMock.getKey.mockResolvedValue(adminKey);
|
||||
|
||||
await expect(sut.validate(token)).resolves.toEqual(authStub.admin);
|
||||
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IKeyRepository } from './api-key.repository';
|
||||
import { APIKeyCreateDto } from './dto/api-key-create.dto';
|
||||
import { APIKeyCreateResponseDto } from './response-dto/api-key-create-response.dto';
|
||||
@@ -55,22 +56,4 @@ export class APIKeyService {
|
||||
const keys = await this.repository.getByUserId(authUser.id);
|
||||
return keys.map(mapKey);
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<AuthUserDto | null> {
|
||||
const hashedToken = this.crypto.hashSha256(token);
|
||||
const keyEntity = await this.repository.getKey(hashedToken);
|
||||
if (keyEntity?.user) {
|
||||
const user = keyEntity.user;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { LoginResponseDto, mapLoginResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain';
|
||||
import cookieParser from 'cookie';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
|
||||
export type JwtValidationResult = {
|
||||
status: boolean;
|
||||
@@ -59,21 +57,4 @@ export class AuthCore {
|
||||
}
|
||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
||||
}
|
||||
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
if (!headers.authorization) {
|
||||
return this.extractTokenFromCookie(cookieParser.parse(headers.cookie || ''));
|
||||
}
|
||||
|
||||
const [type, accessToken] = headers.authorization.split(' ');
|
||||
if (type.toLowerCase() !== 'bearer') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
extractTokenFromCookie(cookies: Record<string, string>) {
|
||||
return cookies?.[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
import { generators, Issuer } from 'openid-client';
|
||||
import { Socket } from 'socket.io';
|
||||
import {
|
||||
userEntityStub,
|
||||
authStub,
|
||||
keyStub,
|
||||
loginResponseStub,
|
||||
newCryptoRepositoryMock,
|
||||
newKeyRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
newUserTokenRepositoryMock,
|
||||
sharedLinkStub,
|
||||
systemConfigStub,
|
||||
userEntityStub,
|
||||
userTokenEntityStub,
|
||||
} from '../../test';
|
||||
import { IKeyRepository } from '../api-key';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { ISharedLinkRepository } from '../share';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
import { AuthType } from './auth.constant';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { SignUpDto } from './dto';
|
||||
import { IUserTokenRepository } from '@app/domain';
|
||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
|
||||
const email = 'test@immich.com';
|
||||
const sub = 'my-auth-user-sub';
|
||||
@@ -51,6 +60,8 @@ describe('AuthService', () => {
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: jest.Mocked<IUserTokenRepository>;
|
||||
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let keyMock: jest.Mocked<IKeyRepository>;
|
||||
let callbackMock: jest.Mock;
|
||||
let create: (config: SystemConfig) => AuthService;
|
||||
|
||||
@@ -81,8 +92,10 @@ describe('AuthService', () => {
|
||||
userMock = newUserRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
keyMock = newKeyRepositoryMock();
|
||||
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, config);
|
||||
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
|
||||
|
||||
sut = create(systemConfigStub.enabled);
|
||||
});
|
||||
@@ -218,63 +231,73 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
describe('validate - socket connections', () => {
|
||||
it('should throw token is not provided', async () => {
|
||||
await expect(sut.validate({}, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
||||
await expect(sut.validate((client as Socket).request.headers)).resolves.toEqual(userEntityStub.user1);
|
||||
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - api request', () => {
|
||||
it('should throw if no user is found', async () => {
|
||||
describe('validate - shared key', () => {
|
||||
it('should not accept a non-existent key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should not accept a key without a user', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validate({ email: 'a', userId: 'test' })).resolves.toBeNull();
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept a valid key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(userEntityStub.admin);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-share-key': 'key' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.adminSharedLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate - user token', () => {
|
||||
it('should throw if no token is found', async () => {
|
||||
userTokenMock.get.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
userMock.get.mockResolvedValue(userEntityStub.user1);
|
||||
userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken);
|
||||
await expect(
|
||||
sut.validate({ cookie: 'immich_access_token=auth_token', email: 'a', userId: 'test' }),
|
||||
).resolves.toEqual(userEntityStub.user1);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokenFromHeader - Cookie', () => {
|
||||
it('should extract the access token', () => {
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: `${IMMICH_ACCESS_COOKIE}=signed-jwt;${IMMICH_AUTH_TYPE_COOKIE}=password`,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toEqual('signed-jwt');
|
||||
describe('validate - api key', () => {
|
||||
it('should throw an error if no api key is found', async () => {
|
||||
keyMock.getKey.mockResolvedValue(null);
|
||||
const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
|
||||
it('should work with no cookies', () => {
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: undefined,
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
|
||||
it('should work on empty cookies', () => {
|
||||
const cookie: IncomingHttpHeaders = {
|
||||
cookie: '',
|
||||
};
|
||||
expect(sut.extractTokenFromHeader(cookie)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTokenFromHeader - Bearer Auth', () => {
|
||||
it('should extract the access token', () => {
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt');
|
||||
});
|
||||
|
||||
it('should work without the auth header', () => {
|
||||
expect(sut.extractTokenFromHeader({})).toBeNull();
|
||||
});
|
||||
|
||||
it('should ignore basic auth', () => {
|
||||
expect(sut.extractTokenFromHeader({ authorization: `Basic stuff` })).toBeNull();
|
||||
it('should return an auth dto', async () => {
|
||||
keyMock.getKey.mockResolvedValue(keyStub.admin);
|
||||
const headers: IncomingHttpHeaders = { 'x-api-key': 'auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual(authStub.admin);
|
||||
expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,16 @@ import { IncomingHttpHeaders } from 'http';
|
||||
import { OAuthCore } from '../oauth/oauth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { AuthType } from './auth.constant';
|
||||
import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant';
|
||||
import { AuthCore } from './auth.core';
|
||||
import { ICryptoRepository } from './crypto.repository';
|
||||
import { ICryptoRepository } from '../crypto/crypto.repository';
|
||||
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
|
||||
import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto';
|
||||
import { IUserTokenRepository, UserTokenCore } from '@app/domain/user-token';
|
||||
import { IUserTokenRepository, UserTokenCore } from '../user-token';
|
||||
import cookieParser from 'cookie';
|
||||
import { ISharedLinkRepository, ShareCore } from '../share';
|
||||
import { APIKeyCore } from '../api-key/api-key.core';
|
||||
import { IKeyRepository } from '../api-key';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -24,14 +28,18 @@ export class AuthService {
|
||||
private authCore: AuthCore;
|
||||
private oauthCore: OAuthCore;
|
||||
private userCore: UserCore;
|
||||
private shareCore: ShareCore;
|
||||
private keyCore: APIKeyCore;
|
||||
|
||||
private logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
|
||||
@Inject(ISharedLinkRepository) shareRepository: ISharedLinkRepository,
|
||||
@Inject(IKeyRepository) keyRepository: IKeyRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG)
|
||||
initialConfig: SystemConfig,
|
||||
) {
|
||||
@@ -39,6 +47,8 @@ export class AuthService {
|
||||
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
|
||||
this.oauthCore = new OAuthCore(configRepository, initialConfig);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
this.shareCore = new ShareCore(shareRepository, cryptoRepository);
|
||||
this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
|
||||
}
|
||||
|
||||
public async login(
|
||||
@@ -115,28 +125,40 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public async validate(headers: IncomingHttpHeaders): Promise<AuthUserDto | null> {
|
||||
const tokenValue = this.extractTokenFromHeader(headers);
|
||||
if (!tokenValue) {
|
||||
return null;
|
||||
public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
|
||||
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
|
||||
const userToken = (headers['x-immich-user-token'] ||
|
||||
params.userToken ||
|
||||
this.getBearerToken(headers) ||
|
||||
this.getCookieToken(headers)) as string;
|
||||
const apiKey = (headers['x-api-key'] || params.apiKey) as string;
|
||||
|
||||
if (shareKey) {
|
||||
return this.shareCore.validate(shareKey);
|
||||
}
|
||||
|
||||
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
||||
const user = await this.userTokenCore.getUserByToken(hashedToken);
|
||||
if (user) {
|
||||
return {
|
||||
...user,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
isAllowDownload: true,
|
||||
isShowExif: true,
|
||||
};
|
||||
if (userToken) {
|
||||
return this.userTokenCore.validate(userToken);
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
return this.keyCore.validate(apiKey);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||
const [type, token] = (headers.authorization || '').split(' ');
|
||||
if (type.toLowerCase() === 'bearer') {
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
extractTokenFromHeader(headers: IncomingHttpHeaders) {
|
||||
return this.authCore.extractTokenFromHeader(headers);
|
||||
private getCookieToken(headers: IncomingHttpHeaders): string | null {
|
||||
const cookies = cookieParser.parse(headers.cookie || '');
|
||||
return cookies[IMMICH_ACCESS_COOKIE] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './auth.constant';
|
||||
export * from './auth.service';
|
||||
export * from './crypto.repository';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
|
||||
1
server/libs/domain/src/crypto/index.ts
Normal file
1
server/libs/domain/src/crypto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './crypto.repository';
|
||||
23
server/libs/domain/src/device-info/device-info.core.ts
Normal file
23
server/libs/domain/src/device-info/device-info.core.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DeviceInfoEntity } from '@app/infra/db/entities';
|
||||
import { IDeviceInfoRepository } from './device-info.repository';
|
||||
|
||||
type UpsertKeys = Pick<DeviceInfoEntity, 'deviceId' | 'userId'>;
|
||||
type UpsertEntity = UpsertKeys & Partial<DeviceInfoEntity>;
|
||||
|
||||
export class DeviceInfoCore {
|
||||
constructor(private repository: IDeviceInfoRepository) {}
|
||||
|
||||
async upsert(entity: UpsertEntity) {
|
||||
const exists = await this.repository.get(entity.userId, entity.deviceId);
|
||||
if (!exists) {
|
||||
if (!entity.isAutoBackup) {
|
||||
entity.isAutoBackup = false;
|
||||
}
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
exists.isAutoBackup = entity.isAutoBackup ?? exists.isAutoBackup;
|
||||
exists.deviceType = entity.deviceType ?? exists.deviceType;
|
||||
return this.repository.save(exists);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DeviceInfoEntity } from '@app/infra/db/entities';
|
||||
|
||||
export const IDeviceInfoRepository = 'IDeviceInfoRepository';
|
||||
|
||||
export interface IDeviceInfoRepository {
|
||||
get(userId: string, deviceId: string): Promise<DeviceInfoEntity | null>;
|
||||
save(entity: Partial<DeviceInfoEntity>): Promise<DeviceInfoEntity>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DeviceInfoEntity, DeviceType } from '@app/infra';
|
||||
import { Repository } from 'typeorm';
|
||||
import { authStub, newDeviceInfoRepositoryMock } from '../../test';
|
||||
import { IDeviceInfoRepository } from './device-info.repository';
|
||||
import { DeviceInfoService } from './device-info.service';
|
||||
|
||||
const deviceId = 'device-123';
|
||||
@@ -7,13 +8,10 @@ const userId = 'user-123';
|
||||
|
||||
describe('DeviceInfoService', () => {
|
||||
let sut: DeviceInfoService;
|
||||
let repositoryMock: jest.Mocked<Repository<DeviceInfoEntity>>;
|
||||
let repositoryMock: jest.Mocked<IDeviceInfoRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
repositoryMock = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
} as unknown as jest.Mocked<Repository<DeviceInfoEntity>>;
|
||||
repositoryMock = newDeviceInfoRepositoryMock();
|
||||
|
||||
sut = new DeviceInfoService(repositoryMock);
|
||||
});
|
||||
@@ -27,12 +25,12 @@ describe('DeviceInfoService', () => {
|
||||
const request = { deviceId, userId, deviceType: DeviceType.IOS } as DeviceInfoEntity;
|
||||
const response = { ...request, id: 1 } as DeviceInfoEntity;
|
||||
|
||||
repositoryMock.findOne.mockResolvedValue(null);
|
||||
repositoryMock.get.mockResolvedValue(null);
|
||||
repositoryMock.save.mockResolvedValue(response);
|
||||
|
||||
await expect(sut.upsert(request)).resolves.toEqual(response);
|
||||
await expect(sut.upsert(authStub.user1, request)).resolves.toEqual(response);
|
||||
|
||||
expect(repositoryMock.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryMock.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -40,12 +38,12 @@ describe('DeviceInfoService', () => {
|
||||
const request = { deviceId, userId, deviceType: DeviceType.IOS, isAutoBackup: true } as DeviceInfoEntity;
|
||||
const response = { ...request, id: 1 } as DeviceInfoEntity;
|
||||
|
||||
repositoryMock.findOne.mockResolvedValue(response);
|
||||
repositoryMock.get.mockResolvedValue(response);
|
||||
repositoryMock.save.mockResolvedValue(response);
|
||||
|
||||
await expect(sut.upsert(request)).resolves.toEqual(response);
|
||||
await expect(sut.upsert(authStub.user1, request)).resolves.toEqual(response);
|
||||
|
||||
expect(repositoryMock.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryMock.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -53,12 +51,12 @@ describe('DeviceInfoService', () => {
|
||||
const request = { deviceId, userId } as DeviceInfoEntity;
|
||||
const response = { id: 1, isAutoBackup: true, deviceId, userId, deviceType: DeviceType.WEB } as DeviceInfoEntity;
|
||||
|
||||
repositoryMock.findOne.mockResolvedValue(response);
|
||||
repositoryMock.get.mockResolvedValue(response);
|
||||
repositoryMock.save.mockResolvedValue(response);
|
||||
|
||||
await expect(sut.upsert(request)).resolves.toEqual(response);
|
||||
await expect(sut.upsert(authStub.user1, request)).resolves.toEqual(response);
|
||||
|
||||
expect(repositoryMock.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryMock.get).toHaveBeenCalledTimes(1);
|
||||
expect(repositoryMock.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
20
server/libs/domain/src/device-info/device-info.service.ts
Normal file
20
server/libs/domain/src/device-info/device-info.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { DeviceInfoCore } from './device-info.core';
|
||||
import { IDeviceInfoRepository } from './device-info.repository';
|
||||
import { UpsertDeviceInfoDto } from './dto';
|
||||
import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceInfoService {
|
||||
private core: DeviceInfoCore;
|
||||
|
||||
constructor(@Inject(IDeviceInfoRepository) repository: IDeviceInfoRepository) {
|
||||
this.core = new DeviceInfoCore(repository);
|
||||
}
|
||||
|
||||
public async upsert(authUser: AuthUserDto, dto: UpsertDeviceInfoDto): Promise<DeviceInfoResponseDto> {
|
||||
const deviceInfo = await this.core.upsert({ ...dto, userId: authUser.id });
|
||||
return mapDeviceInfoResponse(deviceInfo);
|
||||
}
|
||||
}
|
||||
1
server/libs/domain/src/device-info/dto/index.ts
Normal file
1
server/libs/domain/src/device-info/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './upsert-device-info.dto';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IsNotEmpty, IsOptional } from 'class-validator';
|
||||
import { DeviceType } from '@app/infra';
|
||||
import { DeviceType } from '@app/infra/db/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpsertDeviceInfoDto {
|
||||
4
server/libs/domain/src/device-info/index.ts
Normal file
4
server/libs/domain/src/device-info/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './device-info.repository';
|
||||
export * from './device-info.service';
|
||||
export * from './dto';
|
||||
export * from './response-dto';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeviceInfoEntity, DeviceType } from '@app/infra';
|
||||
import { DeviceInfoEntity, DeviceType } from '@app/infra/db/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class DeviceInfoResponseDto {
|
||||
1
server/libs/domain/src/device-info/response-dto/index.ts
Normal file
1
server/libs/domain/src/device-info/response-dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './device-info-response.dto';
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
|
||||
import { APIKeyService } from './api-key';
|
||||
import { AuthService } from './auth';
|
||||
import { DeviceInfoService } from './device-info';
|
||||
import { JobService } from './job';
|
||||
import { OAuthService } from './oauth';
|
||||
import { ShareService } from './share';
|
||||
@@ -10,6 +11,7 @@ import { UserService } from './user';
|
||||
const providers: Provider[] = [
|
||||
APIKeyService,
|
||||
AuthService,
|
||||
DeviceInfoService,
|
||||
JobService,
|
||||
OAuthService,
|
||||
SystemConfigService,
|
||||
|
||||
@@ -2,6 +2,8 @@ export * from './album';
|
||||
export * from './api-key';
|
||||
export * from './asset';
|
||||
export * from './auth';
|
||||
export * from './crypto';
|
||||
export * from './device-info';
|
||||
export * from './domain.module';
|
||||
export * from './job';
|
||||
export * from './oauth';
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
systemConfigStub,
|
||||
userTokenEntityStub,
|
||||
} from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { OAuthService } from '../oauth';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
import { IUserTokenRepository } from '@app/domain';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock';
|
||||
|
||||
const email = 'user@immich.com';
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { SystemConfig } from '@app/infra/db/entities';
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthType, AuthUserDto, ICryptoRepository, LoginResponseDto } from '../auth';
|
||||
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { AuthCore } from '../auth/auth.core';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository, UserCore, UserResponseDto } from '../user';
|
||||
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
|
||||
import { OAuthCore } from './oauth.core';
|
||||
import { OAuthConfigResponseDto } from './response-dto';
|
||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
import { IUserTokenRepository } from '../user-token';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { AssetEntity, SharedLinkEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { CreateSharedLinkDto } from './dto';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
@@ -17,10 +24,6 @@ export class ShareCore {
|
||||
return this.repository.get(userId, id);
|
||||
}
|
||||
|
||||
getByKey(key: string): Promise<SharedLinkEntity | null> {
|
||||
return this.repository.getByKey(key);
|
||||
}
|
||||
|
||||
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
|
||||
try {
|
||||
return this.repository.create({
|
||||
@@ -78,4 +81,26 @@ export class ShareCore {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto | null> {
|
||||
const link = await this.repository.getByKey(key);
|
||||
if (link) {
|
||||
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
|
||||
const user = link.user;
|
||||
if (user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: true,
|
||||
sharedLinkId: link.id,
|
||||
isAllowUpload: link.allowUpload,
|
||||
isAllowDownload: link.allowDownload,
|
||||
isShowExif: link.showExif,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException('Invalid share key');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
authStub,
|
||||
userEntityStub,
|
||||
newCryptoRepositoryMock,
|
||||
newSharedLinkRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
sharedLinkResponseStub,
|
||||
sharedLinkStub,
|
||||
} from '../../test';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { IUserRepository } from '../user';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { ShareService } from './share.service';
|
||||
import { ISharedLinkRepository } from './shared-link.repository';
|
||||
|
||||
@@ -17,44 +14,18 @@ describe(ShareService.name, () => {
|
||||
let sut: ShareService;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let shareMock: jest.Mocked<ISharedLinkRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new ShareService(cryptoMock, shareMock, userMock);
|
||||
sut = new ShareService(cryptoMock, shareMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should not accept a non-existant key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
await expect(sut.validate('key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should not accept an expired key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
await expect(sut.validate('key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should not accept a key without a user', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
|
||||
userMock.get.mockResolvedValue(null);
|
||||
await expect(sut.validate('key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('should accept a valid key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
userMock.get.mockResolvedValue(userEntityStub.admin);
|
||||
await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all keys for a user', async () => {
|
||||
shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
|
||||
@@ -131,20 +102,6 @@ describe(ShareService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByKey', () => {
|
||||
it('should not work on a missing key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(null);
|
||||
await expect(sut.getByKey('secret-key')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
|
||||
});
|
||||
|
||||
it('should find a key', async () => {
|
||||
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.getByKey('secret-key')).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', () => {
|
||||
it('should not work on a missing key', async () => {
|
||||
shareMock.get.mockResolvedValue(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { IUserRepository, UserCore } from '../user';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { EditSharedLinkDto } from './dto';
|
||||
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
|
||||
import { ShareCore } from './share.core';
|
||||
@@ -10,37 +10,12 @@ import { ISharedLinkRepository } from './shared-link.repository';
|
||||
export class ShareService {
|
||||
readonly logger = new Logger(ShareService.name);
|
||||
private shareCore: ShareCore;
|
||||
private userCore: UserCore;
|
||||
|
||||
constructor(
|
||||
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
|
||||
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
|
||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||
) {
|
||||
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
|
||||
this.userCore = new UserCore(userRepository, cryptoRepository);
|
||||
}
|
||||
|
||||
async validate(key: string): Promise<AuthUserDto | null> {
|
||||
const link = await this.shareCore.getByKey(key);
|
||||
if (link) {
|
||||
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
|
||||
const user = await this.userCore.get(link.userId);
|
||||
if (user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: true,
|
||||
sharedLinkId: link.id,
|
||||
isAllowUpload: link.allowUpload,
|
||||
isAllowDownload: link.allowDownload,
|
||||
isShowExif: link.showExif,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
|
||||
@@ -74,14 +49,6 @@ export class ShareService {
|
||||
}
|
||||
}
|
||||
|
||||
async getByKey(key: string): Promise<SharedLinkResponseDto> {
|
||||
const link = await this.shareCore.getByKey(key);
|
||||
if (!link) {
|
||||
throw new BadRequestException('Shared link not found');
|
||||
}
|
||||
return mapSharedLink(link);
|
||||
}
|
||||
|
||||
async remove(authUser: AuthUserDto, id: string): Promise<void> {
|
||||
await this.shareCore.remove(authUser.id, id);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface ISharedLinkRepository {
|
||||
getAll(userId: string): Promise<SharedLinkEntity[]>;
|
||||
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
|
||||
getByKey(key: string): Promise<SharedLinkEntity | null>;
|
||||
create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity>;
|
||||
create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
|
||||
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
|
||||
save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
|
||||
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { UserEntity } from '@app/infra/db/entities';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICryptoRepository } from '../auth';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IUserTokenRepository } from './user-token.repository';
|
||||
|
||||
@Injectable()
|
||||
export class UserTokenCore {
|
||||
constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {}
|
||||
|
||||
async validate(tokenValue: string) {
|
||||
const hashedToken = this.crypto.hashSha256(tokenValue);
|
||||
const user = await this.getUserByToken(hashedToken);
|
||||
if (user) {
|
||||
return {
|
||||
...user,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
isAllowDownload: true,
|
||||
isShowExif: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid user token');
|
||||
}
|
||||
|
||||
public async getUserByToken(tokenValue: string): Promise<UserEntity | null> {
|
||||
const token = await this.repository.get(tokenValue);
|
||||
if (token?.user) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { hash } from 'bcrypt';
|
||||
import { constants, createReadStream, ReadStream } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto } from './dto/create-user.dto';
|
||||
import { IUserRepository, UserListFilter } from './user.repository';
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { UserEntity } from '@app/infra/db/entities';
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { when } from 'jest-when';
|
||||
import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { ReadStream } from 'fs';
|
||||
import { AuthUserDto, ICryptoRepository } from '../auth';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { ICryptoRepository } from '../crypto';
|
||||
import { IUserRepository } from '../user';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
8
server/libs/domain/test/device-info.repository.mock.ts
Normal file
8
server/libs/domain/test/device-info.repository.mock.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IDeviceInfoRepository } from '../src';
|
||||
|
||||
export const newDeviceInfoRepositoryMock = (): jest.Mocked<IDeviceInfoRepository> => {
|
||||
return {
|
||||
get: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
APIKeyEntity,
|
||||
AssetType,
|
||||
SharedLinkEntity,
|
||||
SharedLinkType,
|
||||
@@ -148,6 +149,16 @@ export const userTokenEntityStub = {
|
||||
}),
|
||||
};
|
||||
|
||||
export const keyStub = {
|
||||
admin: Object.freeze({
|
||||
id: 1,
|
||||
name: 'My Key',
|
||||
key: 'my-api-key (hashed)',
|
||||
userId: authStub.admin.id,
|
||||
user: userEntityStub.admin,
|
||||
} as APIKeyEntity),
|
||||
};
|
||||
|
||||
export const systemConfigStub = {
|
||||
defaults: Object.freeze({
|
||||
ffmpeg: {
|
||||
@@ -275,6 +286,7 @@ export const sharedLinkStub = {
|
||||
valid: Object.freeze({
|
||||
id: '123',
|
||||
userId: authStub.admin.id,
|
||||
user: userEntityStub.admin,
|
||||
key: Buffer.from('secret-key', 'utf8'),
|
||||
type: SharedLinkType.ALBUM,
|
||||
createdAt: today.toISOString(),
|
||||
@@ -288,6 +300,7 @@ export const sharedLinkStub = {
|
||||
expired: Object.freeze({
|
||||
id: '123',
|
||||
userId: authStub.admin.id,
|
||||
user: userEntityStub.admin,
|
||||
key: Buffer.from('secret-key', 'utf8'),
|
||||
type: SharedLinkType.ALBUM,
|
||||
createdAt: today.toISOString(),
|
||||
@@ -300,6 +313,7 @@ export const sharedLinkStub = {
|
||||
readonly: Object.freeze<SharedLinkEntity>({
|
||||
id: '123',
|
||||
userId: authStub.admin.id,
|
||||
user: userEntityStub.admin,
|
||||
key: Buffer.from('secret-key', 'utf8'),
|
||||
type: SharedLinkType.ALBUM,
|
||||
createdAt: today.toISOString(),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export * from './api-key.repository.mock';
|
||||
export * from './crypto.repository.mock';
|
||||
export * from './device-info.repository.mock';
|
||||
export * from './fixtures';
|
||||
export * from './job.repository.mock';
|
||||
export * from './shared-link.repository.mock';
|
||||
export * from './system-config.repository.mock';
|
||||
export * from './user-token.repository.mock';
|
||||
export * from './user.repository.mock';
|
||||
|
||||
@@ -32,7 +32,7 @@ export class AssetEntity {
|
||||
webpPath!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, default: '' })
|
||||
encodedVideoPath!: string;
|
||||
encodedVideoPath!: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Column, Entity, Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
|
||||
import { AlbumEntity } from './album.entity';
|
||||
import { AssetEntity } from './asset.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('shared_links')
|
||||
@Unique('UQ_sharedlink_key', ['key'])
|
||||
@@ -14,6 +15,9 @@ export class SharedLinkEntity {
|
||||
@Column()
|
||||
userId!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity)
|
||||
user!: UserEntity;
|
||||
|
||||
@Index('IDX_sharedlink_key')
|
||||
@Column({ type: 'bytea' })
|
||||
key!: Buffer; // use to access the inidividual asset
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSharedLinkUserForeignKeyConstraint1674939383309 implements MigrationInterface {
|
||||
name = 'AddSharedLinkUserForeignKeyConstraint1674939383309';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shared_links" ALTER COLUMN "userId" TYPE varchar(36)`);
|
||||
await queryRunner.query(`ALTER TABLE "shared_links" ALTER COLUMN "userId" TYPE uuid using "userId"::uuid`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340"`);
|
||||
await queryRunner.query(`ALTER TABLE "shared_links" ALTER COLUMN "userId" TYPE character varying`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IDeviceInfoRepository } from '@app/domain';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DeviceInfoEntity } from '../entities';
|
||||
|
||||
export class DeviceInfoRepository implements IDeviceInfoRepository {
|
||||
constructor(@InjectRepository(DeviceInfoEntity) private repository: Repository<DeviceInfoEntity>) {}
|
||||
|
||||
get(userId: string, deviceId: string): Promise<DeviceInfoEntity | null> {
|
||||
return this.repository.findOne({ where: { userId, deviceId } });
|
||||
}
|
||||
|
||||
save(entity: Partial<DeviceInfoEntity>): Promise<DeviceInfoEntity> {
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './api-key.repository';
|
||||
export * from './device-info.repository';
|
||||
export * from './shared-link.repository';
|
||||
export * from './user.repository';
|
||||
export * from './system-config.repository';
|
||||
export * from './user-token.repository';
|
||||
export * from './user.repository';
|
||||
|
||||
@@ -73,6 +73,7 @@ export class SharedLinkRepository implements ISharedLinkRepository {
|
||||
assetInfo: true,
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
},
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ICryptoRepository,
|
||||
IDeviceInfoRepository,
|
||||
IJobRepository,
|
||||
IKeyRepository,
|
||||
ISharedLinkRepository,
|
||||
@@ -7,20 +8,31 @@ import {
|
||||
IUserRepository,
|
||||
QueueName,
|
||||
} from '@app/domain';
|
||||
import { databaseConfig, UserEntity, UserTokenEntity } from './db';
|
||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
import { UserTokenRepository } from '@app/infra/db/repository/user-token.repository';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Global, Module, Provider } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { APIKeyEntity, SharedLinkEntity, SystemConfigEntity, UserRepository } from './db';
|
||||
import { APIKeyRepository, SharedLinkRepository } from './db/repository';
|
||||
import { CryptoRepository } from './auth/crypto.repository';
|
||||
import { SystemConfigRepository } from './db/repository/system-config.repository';
|
||||
import {
|
||||
APIKeyEntity,
|
||||
APIKeyRepository,
|
||||
databaseConfig,
|
||||
DeviceInfoEntity,
|
||||
DeviceInfoRepository,
|
||||
SharedLinkEntity,
|
||||
SharedLinkRepository,
|
||||
SystemConfigEntity,
|
||||
SystemConfigRepository,
|
||||
UserEntity,
|
||||
UserRepository,
|
||||
UserTokenEntity,
|
||||
} from './db';
|
||||
import { JobRepository } from './job';
|
||||
import { IUserTokenRepository } from '@app/domain/user-token';
|
||||
import { UserTokenRepository } from '@app/infra/db/repository/user-token.repository';
|
||||
|
||||
const providers: Provider[] = [
|
||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||
{ provide: IDeviceInfoRepository, useClass: DeviceInfoRepository },
|
||||
{ provide: IKeyRepository, useClass: APIKeyRepository },
|
||||
{ provide: IJobRepository, useClass: JobRepository },
|
||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||
@@ -33,7 +45,14 @@ const providers: Provider[] = [
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot(databaseConfig),
|
||||
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity, UserTokenEntity]),
|
||||
TypeOrmModule.forFeature([
|
||||
APIKeyEntity,
|
||||
DeviceInfoEntity,
|
||||
UserEntity,
|
||||
SharedLinkEntity,
|
||||
SystemConfigEntity,
|
||||
UserTokenEntity,
|
||||
]),
|
||||
BullModule.forRootAsync({
|
||||
useFactory: async () => ({
|
||||
prefix: 'immich_bull',
|
||||
|
||||
@@ -25,7 +25,7 @@ const moveFile = promisify<string, string, mv.Options>(mv);
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
readonly logger = new Logger(StorageService.name);
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
|
||||
private storageTemplate: HandlebarsTemplateDelegate<any>;
|
||||
|
||||
|
||||
108
server/package-lock.json
generated
108
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.43.1",
|
||||
"version": "1.44.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.42.0",
|
||||
"version": "1.43.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/bull": "^0.6.2",
|
||||
@@ -14,7 +14,6 @@
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/mapped-types": "1.2.0",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@nestjs/platform-socket.io": "^9.2.1",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
@@ -46,9 +45,6 @@
|
||||
"mv": "^2.1.1",
|
||||
"nest-commander": "^3.3.0",
|
||||
"openid-client": "^5.2.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-http-header-strategy": "^1.1.0",
|
||||
"pg": "^8.8.0",
|
||||
"redis": "^4.5.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
@@ -1537,15 +1533,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/passport": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-9.0.0.tgz",
|
||||
"integrity": "sha512-Gnh8n1wzFPOLSS/94X1sUP4IRAoXTgG4odl7/AO5h+uwscEGXxJFercrZfqdAwkWhqkKWbsntM3j5mRy/6ZQDA==",
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^8.0.0 || ^9.0.0",
|
||||
"passport": "^0.4.0 || ^0.5.0 || ^0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-9.2.1.tgz",
|
||||
@@ -8869,50 +8856,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/passport": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz",
|
||||
"integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==",
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
"utils-merge": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jaredhanson"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-custom": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
|
||||
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-http-header-strategy": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",
|
||||
"integrity": "sha512-Gn60rR55UE1wXbVhnnfG3yyeRSz5pzz3n6rppxa6xiOo4gGPh/onuw29HuGjpk9DSzXRFkJn95+8RT11kXHeWA==",
|
||||
"dependencies": {
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -8964,11 +8907,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pause": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz",
|
||||
@@ -12666,12 +12604,6 @@
|
||||
"integrity": "sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@nestjs/passport": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-9.0.0.tgz",
|
||||
"integrity": "sha512-Gnh8n1wzFPOLSS/94X1sUP4IRAoXTgG4odl7/AO5h+uwscEGXxJFercrZfqdAwkWhqkKWbsntM3j5mRy/6ZQDA==",
|
||||
"requires": {}
|
||||
},
|
||||
"@nestjs/platform-express": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-9.2.1.tgz",
|
||||
@@ -18330,37 +18262,6 @@
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
|
||||
},
|
||||
"passport": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz",
|
||||
"integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==",
|
||||
"requires": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
"utils-merge": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"passport-custom": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
|
||||
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
|
||||
"requires": {
|
||||
"passport-strategy": "1.x.x"
|
||||
}
|
||||
},
|
||||
"passport-http-header-strategy": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz",
|
||||
"integrity": "sha512-Gn60rR55UE1wXbVhnnfG3yyeRSz5pzz3n6rppxa6xiOo4gGPh/onuw29HuGjpk9DSzXRFkJn95+8RT11kXHeWA==",
|
||||
"requires": {
|
||||
"passport-strategy": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ="
|
||||
},
|
||||
"path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -18400,11 +18301,6 @@
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
|
||||
},
|
||||
"pause": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
|
||||
},
|
||||
"pbf": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.43.1",
|
||||
"version": "1.44.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -43,7 +43,6 @@
|
||||
"@nestjs/config": "^2.2.0",
|
||||
"@nestjs/core": "^9.2.1",
|
||||
"@nestjs/mapped-types": "1.2.0",
|
||||
"@nestjs/passport": "^9.0.0",
|
||||
"@nestjs/platform-express": "^9.2.1",
|
||||
"@nestjs/platform-socket.io": "^9.2.1",
|
||||
"@nestjs/schedule": "^2.1.0",
|
||||
@@ -75,9 +74,6 @@
|
||||
"mv": "^2.1.1",
|
||||
"nest-commander": "^3.3.0",
|
||||
"openid-client": "^5.2.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-http-header-strategy": "^1.1.0",
|
||||
"pg": "^8.8.0",
|
||||
"redis": "^4.5.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
@@ -147,10 +143,10 @@
|
||||
"statements": 20
|
||||
},
|
||||
"./libs/domain/": {
|
||||
"branches": 75,
|
||||
"functions": 85,
|
||||
"lines": 90,
|
||||
"statements": 90
|
||||
"branches": 80,
|
||||
"functions": 90,
|
||||
"lines": 95,
|
||||
"statements": 95
|
||||
}
|
||||
},
|
||||
"testEnvironment": "node",
|
||||
|
||||
Reference in New Issue
Block a user