1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 06:31:58 +00:00

refactor(server): move asset detail endpoint to new controller (#6636)

* refactor(server): move asset by id to new controller

* chore: open api

* refactor: more consolidation

* refactor: asset service
This commit is contained in:
Jason Rasmussen 2024-01-25 12:52:21 -05:00 committed by GitHub
parent 19d4c5e9f7
commit b306cf564e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 421 additions and 174 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1057,6 +1057,7 @@
},
"/asset/assetById/{id}": {
"get": {
"deprecated": true,
"description": "Get a single asset's information",
"operationId": "getAssetById",
"parameters": [
@ -2319,6 +2320,54 @@
}
},
"/asset/{id}": {
"get": {
"operationId": "getAssetInfo",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Asset"
]
},
"put": {
"operationId": "updateAsset",
"parameters": [

View file

@ -7147,6 +7147,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* @param {string} id
* @param {string} [key]
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
getAssetById: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
@ -7180,6 +7181,53 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAssetInfo: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getAssetInfo', 'id', id)
const localVarPath = `/asset/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -8609,12 +8657,24 @@ export const AssetApiFp = function(configuration?: Configuration) {
* @param {string} id
* @param {string} [key]
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
async getAssetById(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetById(id, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAssetInfo(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetInfo(id, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@ -8982,11 +9042,21 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* Get a single asset\'s information
* @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
*/
getAssetById(requestParameters: AssetApiGetAssetByIdRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
return localVarFp.getAssetById(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiGetAssetInfoRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAssetInfo(requestParameters: AssetApiGetAssetInfoRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
return localVarFp.getAssetInfo(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@ -9348,6 +9418,27 @@ export interface AssetApiGetAssetByIdRequest {
readonly key?: string
}
/**
* Request parameters for getAssetInfo operation in AssetApi.
* @export
* @interface AssetApiGetAssetInfoRequest
*/
export interface AssetApiGetAssetInfoRequest {
/**
*
* @type {string}
* @memberof AssetApiGetAssetInfo
*/
readonly id: string
/**
*
* @type {string}
* @memberof AssetApiGetAssetInfo
*/
readonly key?: string
}
/**
* Request parameters for getAssetStatistics operation in AssetApi.
* @export
@ -10272,6 +10363,7 @@ export class AssetApi extends BaseAPI {
* Get a single asset\'s information
* @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @deprecated
* @throws {RequiredError}
* @memberof AssetApi
*/
@ -10279,6 +10371,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getAssetById(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetAssetInfoRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getAssetInfo(requestParameters: AssetApiGetAssetInfoRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAssetInfo(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.

View file

@ -542,6 +542,109 @@ describe(`${AssetController.name} (e2e)`, () => {
}
});
// TODO remove with deprecated endpoint
describe('GET /asset/assetById/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/asset/assetById/${uuidStub.notFound}`);
expect(body).toEqual(errorStub.unauthorized);
expect(status).toBe(401);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.get(`/asset/assetById/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(server)
.get(`/asset/assetById/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should get the asset info', async () => {
const { status, body } = await request(server)
.get(`/asset/assetById/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: asset1.id,
stack: [],
stackCount: 0,
});
});
it('should work with a shared link', async () => {
const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
});
const { status, body } = await request(server).get(`/asset/assetById/${asset1.id}?key=${sharedLink.key}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: asset1.id,
stack: [],
stackCount: 0,
});
});
});
describe('GET /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/asset/${uuidStub.notFound}`);
expect(body).toEqual(errorStub.unauthorized);
expect(status).toBe(401);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.get(`/asset/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(server)
.get(`/asset/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should get the asset info', async () => {
const { status, body } = await request(server)
.get(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: asset1.id,
stack: [],
stackCount: 0,
});
});
it('should work with a shared link', async () => {
const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
});
const { status, body } = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: asset1.id,
stack: [],
stackCount: 0,
});
});
});
describe('POST /asset/upload', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)

View file

@ -8,7 +8,6 @@ import {
newAccessRepositoryMock,
newAssetRepositoryMock,
newCommunicationRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newPartnerRepositoryMock,
newStorageRepositoryMock,
@ -24,7 +23,6 @@ import {
ClientEvent,
IAssetRepository,
ICommunicationRepository,
ICryptoRepository,
IJobRepository,
IPartnerRepository,
IStorageRepository,
@ -168,7 +166,6 @@ describe(AssetService.name, () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
@ -184,7 +181,6 @@ describe(AssetService.name, () => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
communicationMock = newCommunicationRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
@ -194,7 +190,6 @@ describe(AssetService.name, () => {
sut = new AssetService(
accessMock,
assetMock,
cryptoMock,
jobMock,
configMock,
storageMock,
@ -657,6 +652,59 @@ describe(AssetService.name, () => {
});
});
describe('get', () => {
it('should allow owner access', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.admin, assetStub.image.id);
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
});
it('should allow shared link access', async () => {
accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.adminSharedLink, assetStub.image.id);
expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id,
new Set([assetStub.image.id]),
);
});
it('should allow partner sharing access', async () => {
accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.admin, assetStub.image.id);
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
});
it('should allow shared album access', async () => {
accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue(assetStub.image);
await sut.get(authStub.admin, assetStub.image.id);
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
});
it('should throw an error for no access', async () => {
await expect(sut.get(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
expect(assetMock.getById).not.toHaveBeenCalled();
});
it('should throw an error for an invalid shared link', async () => {
await expect(sut.get(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
expect(assetMock.getById).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('should require asset write access for the id', async () => {
await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(

View file

@ -15,7 +15,6 @@ import {
IAccessRepository,
IAssetRepository,
ICommunicationRepository,
ICryptoRepository,
IJobRepository,
IPartnerRepository,
IStorageRepository,
@ -87,7 +86,6 @@ export class AssetService {
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@ -400,6 +398,44 @@ export class AssetService {
return this.assetRepository.getAllByDeviceId(auth.user.id, deviceId);
}
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_READ, id);
const asset = await this.assetRepository.getById(id, {
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: true,
owner: true,
faces: {
person: true,
},
stack: {
exifInfo: true,
},
});
if (!asset) {
throw new BadRequestException('Asset not found');
}
if (auth.sharedLink && !auth.sharedLink.showExif) {
return mapAsset(asset, { stripMetadata: true, withStack: true });
}
const data = mapAsset(asset, { withStack: true });
if (auth.sharedLink) {
delete data.owner;
}
if (data.ownerId !== auth.user.id) {
data.people = [];
}
return data;
}
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);

View file

@ -20,12 +20,11 @@ export interface AssetOwnerCheck extends AssetCheck {
ownerId: string;
}
export interface IAssetRepository {
export interface IAssetRepositoryV1 {
get(id: string): Promise<AssetEntity | null>;
create(asset: AssetCreate): Promise<AssetEntity>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getById(assetId: string): Promise<AssetEntity>;
getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
@ -34,10 +33,10 @@ export interface IAssetRepository {
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
}
export const IAssetRepository = 'IAssetRepository';
export const IAssetRepositoryV1 = 'IAssetRepositoryV1';
@Injectable()
export class AssetRepository implements IAssetRepository {
export class AssetRepositoryV1 implements IAssetRepositoryV1 {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@ -93,34 +92,6 @@ export class AssetRepository implements IAssetRepository {
);
}
/**
* Get a single asset information by its ID
* - include exif info
* @param assetId
*/
getById(assetId: string): Promise<AssetEntity> {
return this.assetRepository.findOneOrFail({
where: {
id: assetId,
},
relations: {
exifInfo: true,
tags: true,
sharedLinks: true,
smartInfo: true,
owner: true,
faces: {
person: true,
},
stack: {
exifInfo: true,
},
},
// We are specifically asking for this asset. Return it even if it is soft deleted
withDeleted: true,
});
}
/**
* Get all assets belong to the user on the database
* @param ownerId

View file

@ -1,4 +1,4 @@
import { AssetResponseDto, AuthDto } from '@app/domain';
import { AssetResponseDto, AssetService, AuthDto } from '@app/domain';
import {
Body,
Controller,
@ -18,11 +18,11 @@ import {
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../../app.guard';
import { sendFile } from '../../app.utils';
import { UseValidation, sendFile } from '../../app.utils';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../interceptors';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
import { AssetService } from './asset.service';
import { AssetService as AssetServiceV1 } from './asset.service';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
@ -45,7 +45,10 @@ interface UploadFiles {
@Controller(Route.ASSET)
@Authenticated()
export class AssetController {
constructor(private assetService: AssetService) {}
constructor(
private serviceV1: AssetServiceV1,
private service: AssetService,
) {}
@SharedLinkRoute()
@Post('upload')
@ -74,7 +77,7 @@ export class AssetController {
sidecarFile = mapToUploadFile(_sidecarFile);
}
const responseDto = await this.assetService.uploadFile(auth, dto, file, livePhotoFile, sidecarFile);
const responseDto = await this.serviceV1.uploadFile(auth, dto, file, livePhotoFile, sidecarFile);
if (responseDto.duplicate) {
res.status(HttpStatus.OK);
}
@ -92,7 +95,7 @@ export class AssetController {
@Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: ServeFileDto,
) {
await sendFile(res, next, () => this.assetService.serveFile(auth, id, dto));
await sendFile(res, next, () => this.serviceV1.serveFile(auth, id, dto));
}
@SharedLinkRoute()
@ -105,22 +108,22 @@ export class AssetController {
@Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto,
) {
await sendFile(res, next, () => this.assetService.serveThumbnail(auth, id, dto));
await sendFile(res, next, () => this.serviceV1.serveThumbnail(auth, id, dto));
}
@Get('/curated-objects')
getCuratedObjects(@Auth() auth: AuthDto): Promise<CuratedObjectsResponseDto[]> {
return this.assetService.getCuratedObject(auth);
return this.serviceV1.getCuratedObject(auth);
}
@Get('/curated-locations')
getCuratedLocations(@Auth() auth: AuthDto): Promise<CuratedLocationsResponseDto[]> {
return this.assetService.getCuratedLocation(auth);
return this.serviceV1.getCuratedLocation(auth);
}
@Get('/search-terms')
getAssetSearchTerms(@Auth() auth: AuthDto): Promise<string[]> {
return this.assetService.getAssetSearchTerm(auth);
return this.serviceV1.getAssetSearchTerm(auth);
}
/**
@ -137,16 +140,18 @@ export class AssetController {
@Auth() auth: AuthDto,
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
): Promise<AssetResponseDto[]> {
return this.assetService.getAllAssets(auth, dto);
return this.serviceV1.getAllAssets(auth, dto);
}
/**
* Get a single asset's information
* @deprecated Use `/asset/:id`
*/
@SharedLinkRoute()
@UseValidation()
@Get('/assetById/:id')
getAssetById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.assetService.getAssetById(auth, id) as Promise<AssetResponseDto>;
return this.service.get(auth, id) as Promise<AssetResponseDto>;
}
/**
@ -158,7 +163,7 @@ export class AssetController {
@Auth() auth: AuthDto,
@Body(ValidationPipe) dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.assetService.checkExistingAssets(auth, dto);
return this.serviceV1.checkExistingAssets(auth, dto);
}
/**
@ -170,6 +175,6 @@ export class AssetController {
@Auth() auth: AuthDto,
@Body(ValidationPipe) dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
return this.assetService.bulkUploadCheck(auth, dto);
return this.serviceV1.bulkUploadCheck(auth, dto);
}
}

View file

@ -2,12 +2,12 @@ import { AuthDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/do
import { AssetEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository';
import { IAssetRepositoryV1 } from './asset-repository';
import { CreateAssetDto } from './dto/create-asset.dto';
export class AssetCore {
constructor(
private repository: IAssetRepository,
private repository: IAssetRepositoryV1,
private jobRepository: IJobRepository,
) {}

View file

@ -1,19 +1,19 @@
import { IJobRepository, ILibraryRepository, IUserRepository, JobName } from '@app/domain';
import { IAssetRepository, IJobRepository, ILibraryRepository, IUserRepository, JobName } from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
IAccessRepositoryMock,
assetStub,
authStub,
fileStub,
newAccessRepositoryMock,
newAssetRepositoryMock,
newJobRepositoryMock,
newLibraryRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { QueryFailedError } from 'typeorm';
import { IAssetRepository } from './asset-repository';
import { IAssetRepositoryV1 } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
@ -59,19 +59,19 @@ const _getAsset_1 = () => {
describe('AssetService', () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let assetRepositoryMockV1: jest.Mocked<IAssetRepositoryV1>;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => {
assetRepositoryMock = {
assetRepositoryMockV1 = {
get: jest.fn(),
create: jest.fn(),
upsertExif: jest.fn(),
getAllByUserId: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
@ -81,16 +81,17 @@ describe('AssetService', () => {
};
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AssetService(accessMock, assetRepositoryMock, jobMock, libraryMock, userMock);
sut = new AssetService(accessMock, assetRepositoryMockV1, assetMock, jobMock, libraryMock, userMock);
when(assetRepositoryMock.get)
when(assetRepositoryMockV1.get)
.calledWith(assetStub.livePhotoStillAsset.id)
.mockResolvedValue(assetStub.livePhotoStillAsset);
when(assetRepositoryMock.get)
when(assetRepositoryMockV1.get)
.calledWith(assetStub.livePhotoMotionAsset.id)
.mockResolvedValue(assetStub.livePhotoMotionAsset);
});
@ -108,12 +109,12 @@ describe('AssetService', () => {
};
const dto = _getCreateAssetDto();
assetRepositoryMock.create.mockResolvedValue(assetEntity);
assetRepositoryMockV1.create.mockResolvedValue(assetEntity);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
expect(assetRepositoryMockV1.create).toHaveBeenCalled();
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
});
@ -130,8 +131,8 @@ describe('AssetService', () => {
const error = new QueryFailedError('', [], new Error('unique key violation'));
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
assetRepositoryMockV1.create.mockRejectedValue(error);
assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
@ -148,8 +149,8 @@ describe('AssetService', () => {
const error = new QueryFailedError('', [], new Error('unique key violation'));
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
assetRepositoryMockV1.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetRepositoryMockV1.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(
@ -177,7 +178,7 @@ describe('AssetService', () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([
assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([
{ id: 'asset-1', checksum: file1 },
{ id: 'asset-2', checksum: file2 },
]);
@ -196,62 +197,7 @@ describe('AssetService', () => {
],
});
expect(assetRepositoryMock.getAssetsByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
});
});
describe('getAssetById', () => {
it('should allow owner access', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id);
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
});
it('should allow shared link access', async () => {
accessMock.asset.checkSharedLinkAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.adminSharedLink, assetStub.image.id);
expect(accessMock.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLink?.id,
new Set([assetStub.image.id]),
);
});
it('should allow partner sharing access', async () => {
accessMock.asset.checkPartnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id);
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
});
it('should allow shared album access', async () => {
accessMock.asset.checkAlbumAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id);
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
});
it('should throw an error for no access', async () => {
await expect(sut.getAssetById(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
it('should throw an error for an invalid shared link', async () => {
await expect(sut.getAssetById(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
expect(assetRepositoryMockV1.getAssetsByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
});
});
});

View file

@ -3,24 +3,24 @@ import {
AssetResponseDto,
AuthDto,
CacheControl,
getLivePhotoMotionFilename,
IAccessRepository,
IAssetRepository,
IJobRepository,
ILibraryRepository,
ImmichFileResponse,
IUserRepository,
ImmichFileResponse,
JobName,
Permission,
UploadFile,
getLivePhotoMotionFilename,
mapAsset,
mimeTypes,
Permission,
SanitizedAssetResponseDto,
UploadFile,
} from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
import { IAssetRepository } from './asset-repository';
import { IAssetRepositoryV1 } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
@ -47,12 +47,13 @@ export class AssetService {
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(IAssetRepositoryV1) private assetRepositoryV1: IAssetRepositoryV1,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private libraryRepository: ILibraryRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.assetCore = new AssetCore(assetRepositoryV1, jobRepository);
this.access = AccessCore.create(accessRepository);
}
@ -102,7 +103,7 @@ export class AssetService {
// handle duplicates with a success response
if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) {
const checksums = [file.checksum, livePhotoFile?.checksum].filter((checksum): checksum is Buffer => !!checksum);
const [duplicate] = await this._assetRepository.getAssetsByChecksums(auth.user.id, checksums);
const [duplicate] = await this.assetRepositoryV1.getAssetsByChecksums(auth.user.id, checksums);
return { id: duplicate.id, duplicate: true };
}
@ -114,35 +115,14 @@ export class AssetService {
public async getAllAssets(auth: AuthDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const assets = await this._assetRepository.getAllByUserId(userId, dto);
const assets = await this.assetRepositoryV1.getAllByUserId(userId, dto);
return assets.map((asset) => mapAsset(asset));
}
public async getAssetById(auth: AuthDto, assetId: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.access.requirePermission(auth, Permission.ASSET_READ, assetId);
const asset = await this._assetRepository.getById(assetId);
if (!auth.sharedLink || auth.sharedLink?.showExif) {
const data = mapAsset(asset, { withStack: true });
if (data.ownerId !== auth.user.id) {
data.people = [];
}
if (auth.sharedLink) {
delete data.owner;
}
return data;
} else {
return mapAsset(asset, { stripMetadata: true, withStack: true });
}
}
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
const asset = await this._assetRepository.get(assetId);
const asset = await this.assetRepositoryV1.get(assetId);
if (!asset) {
throw new NotFoundException('Asset not found');
}
@ -160,7 +140,7 @@ export class AssetService {
// this is not quite right as sometimes this returns the original still
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
const asset = await this._assetRepository.getById(assetId);
const asset = await this.assetRepository.getById(assetId);
if (!asset) {
throw new NotFoundException('Asset does not exist');
}
@ -182,7 +162,7 @@ export class AssetService {
async getAssetSearchTerm(auth: AuthDto): Promise<string[]> {
const possibleSearchTerm = new Set<string>();
const rows = await this._assetRepository.getSearchPropertiesByUserId(auth.user.id);
const rows = await this.assetRepositoryV1.getSearchPropertiesByUserId(auth.user.id);
rows.forEach((row: SearchPropertiesDto) => {
// tags
row.tags?.map((tag: string) => possibleSearchTerm.add(tag?.toLowerCase()));
@ -213,11 +193,11 @@ export class AssetService {
}
async getCuratedLocation(auth: AuthDto): Promise<CuratedLocationsResponseDto[]> {
return this._assetRepository.getLocationsByUserId(auth.user.id);
return this.assetRepositoryV1.getLocationsByUserId(auth.user.id);
}
async getCuratedObject(auth: AuthDto): Promise<CuratedObjectsResponseDto[]> {
return this._assetRepository.getDetectedObjectsByUserId(auth.user.id);
return this.assetRepositoryV1.getDetectedObjectsByUserId(auth.user.id);
}
async checkExistingAssets(
@ -225,7 +205,7 @@ export class AssetService {
checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return {
existingIds: await this._assetRepository.getExistingAssets(auth.user.id, checkExistingAssetsDto),
existingIds: await this.assetRepositoryV1.getExistingAssets(auth.user.id, checkExistingAssetsDto),
};
}
@ -238,7 +218,7 @@ export class AssetService {
}
const checksums: Buffer[] = dto.assets.map((asset) => Buffer.from(asset.checksum, 'hex'));
const results = await this._assetRepository.getAssetsByChecksums(auth.user.id, checksums);
const results = await this.assetRepositoryV1.getAssetsByChecksums(auth.user.id, checksums);
const checksumMap: Record<string, string> = {};
for (const { id, checksum } of results) {

View file

@ -5,7 +5,7 @@ import { Module, OnModuleInit } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository';
import { AssetRepositoryV1, IAssetRepositoryV1 } from './api-v1/asset/asset-repository';
import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller';
import { AssetService } from './api-v1/asset/asset.service';
import { AppGuard } from './app.guard';
@ -45,8 +45,8 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
controllers: [
ActivityController,
AssetsController,
AssetController,
AssetControllerV1,
AssetController,
AppController,
AlbumController,
APIKeyController,
@ -68,7 +68,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
providers: [
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_GUARD, useClass: AppGuard },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
AppService,
AssetService,
FileUploadInterceptor,

View file

@ -176,6 +176,12 @@ export class AssetController {
return this.service.updateStackParent(auth, dto);
}
@SharedLinkRoute()
@Get(':id')
getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.service.get(auth, id) as Promise<AssetResponseDto>;
}
@Put(':id')
updateAsset(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise<AssetResponseDto> {
return this.service.update(auth, id, dto);

View file

@ -62,7 +62,7 @@
// Get latest description from server
if (newAsset.id && !api.isSharedLink) {
const { data } = await api.assetApi.getAssetById({ id: asset.id });
const { data } = await api.assetApi.getAssetInfo({ id: asset.id });
people = data?.people || [];
description = data.exifInfo?.description || '';
@ -126,7 +126,7 @@
};
const handleRefreshPeople = async () => {
await api.assetApi.getAssetById({ id: asset.id }).then((res) => {
await api.assetApi.getAssetInfo({ id: asset.id }).then((res) => {
people = res.data?.people || [];
textArea.value = res.data?.exifInfo?.description || '';
});
@ -234,7 +234,7 @@
{/key}
</section>
{:else if description}
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
<p class="px-4 break-words whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
{/if}
{#if !api.isSharedLink && people.length > 0}

View file

@ -27,7 +27,7 @@
assetId = link.assets[0].id;
}
const { data } = await api.assetApi.getAssetById({ id: assetId });
const { data } = await api.assetApi.getAssetInfo({ id: assetId });
return data;
};

View file

@ -6,7 +6,7 @@ function createAssetViewingStore() {
const viewState = writable<boolean>(false);
const setAssetId = async (id: string) => {
const { data } = await api.assetApi.getAssetById({ id, key: api.getKey() });
const { data } = await api.assetApi.getAssetInfo({ id, key: api.getKey() });
viewingAssetStoreState.set(data);
viewState.set(true);
};

View file

@ -3,7 +3,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ params }) => {
const { key, assetId } = params;
const { data: asset } = await api.assetApi.getAssetById({ id: assetId, key });
const { data: asset } = await api.assetApi.getAssetInfo({ id: assetId, key });
return {
asset,