Merge branch 'main' into feat/search-filter-album/web

This commit is contained in:
CJPeckover
2025-10-08 23:36:08 -04:00
2033 changed files with 182993 additions and 125013 deletions
@@ -46,8 +46,8 @@ export class ActivityController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.ActivityDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -84,6 +84,13 @@ describe(AlbumController.name, () => {
});
});
describe('PUT /albums/assets', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/albums/assets`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PATCH /albums/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
+18 -7
View File
@@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto,
CreateAlbumDto,
GetAlbumsDto,
@@ -68,12 +70,13 @@ export class AlbumController {
@Delete(':id')
@Authenticated({ permission: Permission.AlbumDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}
@Put(':id/assets')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
addAssetsToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -82,8 +85,14 @@ export class AlbumController {
return this.service.addAssets(auth, id, dto);
}
@Put('assets')
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
return this.service.addAssetsToAlbums(auth, dto);
}
@Delete(':id/assets')
@Authenticated()
@Authenticated({ permission: Permission.AlbumAssetDelete })
removeAssetFromAlbum(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@@ -93,7 +102,7 @@ export class AlbumController {
}
@Put(':id/users')
@Authenticated()
@Authenticated({ permission: Permission.AlbumUserCreate })
addUsersToAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -103,7 +112,8 @@ export class AlbumController {
}
@Put(':id/user/:userId')
@Authenticated()
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -114,12 +124,13 @@ export class AlbumController {
}
@Delete(':id/user/:userId')
@Authenticated()
@Authenticated({ permission: Permission.AlbumUserDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
): Promise<void> {
return this.service.removeUser(auth, id, userId);
}
}
@@ -1,16 +1,16 @@
import { APIKeyController } from 'src/controllers/api-key.controller';
import { ApiKeyController } from 'src/controllers/api-key.controller';
import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(APIKeyController.name, () => {
describe(ApiKeyController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(ApiKeyService);
beforeAll(async () => {
ctx = await controllerSetup(APIKeyController, [{ provide: ApiKeyService, useValue: service }]);
ctx = await controllerSetup(ApiKeyController, [{ provide: ApiKeyService, useValue: service }]);
return () => ctx.close();
});
@@ -33,6 +33,13 @@ describe(APIKeyController.name, () => {
});
});
describe('GET /api-keys/me', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/me`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /api-keys/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/api-keys/${factory.uuid()}`);
+8 -2
View File
@@ -9,7 +9,7 @@ import { UUIDParamDto } from 'src/validation';
@ApiTags('API Keys')
@Controller('api-keys')
export class APIKeyController {
export class ApiKeyController {
constructor(private service: ApiKeyService) {}
@Post()
@@ -24,6 +24,12 @@ export class APIKeyController {
return this.service.getAll(auth);
}
@Get('me')
@Authenticated({ permission: false })
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
return this.service.getMine(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.ApiKeyRead })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
@@ -41,8 +47,8 @@ export class APIKeyController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.ApiKeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -1,4 +1,6 @@
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { AssetMetadataKey } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import request from 'supertest';
@@ -11,7 +13,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
isFavorite: 'testing',
isFavorite: 'false',
duration: '0:00:00.000000',
};
@@ -27,16 +29,20 @@ describe(AssetMediaController.name, () => {
let ctx: ControllerContext;
const assetData = Buffer.from('123');
const filename = 'example.png';
const service = mockBaseService(AssetMediaService);
beforeAll(async () => {
ctx = await controllerSetup(AssetMediaController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: AssetMediaService, useValue: mockBaseService(AssetMediaService) },
{ provide: AssetMediaService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
service.uploadAsset.mockResolvedValue({ status: AssetMediaStatus.DUPLICATE, id: factory.uuid() });
ctx.reset();
});
@@ -46,13 +52,61 @@ describe(AssetMediaController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should accept metadata', async () => {
const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } };
const { status } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([mobileMetadata]),
});
expect(service.uploadAsset).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ metadata: [mobileMetadata] }),
expect.objectContaining({ originalName: 'example.png' }),
undefined,
);
expect(status).toBe(200);
});
it('should handle invalid metadata json', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: 'not-a-string-string',
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
});
it('should validate iCloudId is a string', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({
...makeUploadDto(),
metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]),
});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string']));
});
it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
);
});
it('should require `deviceId`', async () => {
@@ -61,7 +115,7 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty']));
});
it('should require `fileCreatedAt`', async () => {
@@ -70,25 +124,20 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
);
});
it('should require `fileModifiedAt`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'fileModifiedAt' }) });
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
});
it('should require `duration`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'duration' }) });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']),
);
});
it('should throw if `isFavorite` is not a boolean', async () => {
@@ -97,16 +146,18 @@ describe(AssetMediaController.name, () => {
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
});
it('should throw if `visibility` is not an enum', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), visibility: 'not-a-boolean' });
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
);
});
// TODO figure out how to deal with `sendFile`
@@ -34,7 +34,7 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichHeader, RouteKey } from 'src/enum';
import { ImmichHeader, Permission, RouteKey } from 'src/enum';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
@@ -61,7 +61,7 @@ export class AssetMediaController {
required: false,
})
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
async uploadAsset(
@Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@@ -80,7 +80,7 @@ export class AssetMediaController {
@Get(':id/original')
@FileResponse()
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
async downloadAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -96,12 +96,13 @@ export class AssetMediaController {
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@EndpointLifecycle({ addedAt: 'v1.106.0' })
@ApiOperation({
@EndpointLifecycle({
addedAt: 'v1.106.0',
deprecatedAt: 'v1.142.0',
summary: 'replaceAsset',
description: 'Replace the asset with new file, without changing its id',
})
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -120,7 +121,7 @@ export class AssetMediaController {
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
async viewAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -157,7 +158,7 @@ export class AssetMediaController {
@Get(':id/video/playback')
@FileResponse()
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
async playAssetVideo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -171,12 +172,12 @@ export class AssetMediaController {
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('exist')
@HttpCode(HttpStatus.OK)
@Authenticated()
@ApiOperation({
summary: 'checkExistingAssets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
})
@Authenticated()
@HttpCode(HttpStatus.OK)
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
@@ -188,12 +189,12 @@ export class AssetMediaController {
* Checks if assets exist by checksums
*/
@Post('bulk-upload-check')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetUpload })
@ApiOperation({
summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums',
})
@Authenticated()
@HttpCode(HttpStatus.OK)
checkBulkUpload(
@Auth() auth: AuthDto,
@Body() dto: AssetBulkUploadCheckDto,
+120 -1
View File
@@ -1,4 +1,5 @@
import { AssetController } from 'src/controllers/asset.controller';
import { AssetMetadataKey } from 'src/enum';
import { AssetService } from 'src/services/asset.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
@@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'
describe(AssetController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(AssetService);
beforeAll(async () => {
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]);
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
ctx.reset();
service.resetAllMocks();
});
describe('PUT /assets', () => {
@@ -115,4 +118,120 @@ describe(AssetController.name, () => {
);
});
});
describe('GET /assets/:id/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /assets/:id/metadata', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] });
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require items to be an array', async () => {
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['items must be an array']));
});
it('should require each item to have a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/metadata`)
.send({ items: [{ key: 'someKey' }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
),
);
});
it('should require each item to have a value', async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/assets/${factory.uuid()}/metadata`)
.send({ items: [{ key: 'mobile-app', value: null }] });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])),
);
});
describe(AssetMetadataKey.MobileApp, () => {
it('should accept valid data and pass to service correctly', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer())
.put(`/assets/${assetId}/metadata`)
.send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] });
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
items: [{ key: 'mobile-app', value: { iCloudId: '123' } }],
});
expect(status).toBe(200);
});
it('should work without iCloudId', async () => {
const assetId = factory.uuid();
const { status } = await request(ctx.getHttpServer())
.put(`/assets/${assetId}/metadata`)
.send({ items: [{ key: 'mobile-app', value: {} }] });
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
items: [{ key: 'mobile-app', value: {} }],
});
expect(status).toBe(200);
});
});
});
describe('GET /assets/:id/metadata/:key', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(
expect.arrayContaining([expect.stringContaining('key must be one of the following value')]),
),
);
});
});
describe('DELETE /assets/:id/metadata/:key', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a valid id', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
});
it('should require a valid key', async () => {
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`);
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]),
);
});
});
});
+42 -7
View File
@@ -6,6 +6,9 @@ import {
AssetBulkDeleteDto,
AssetBulkUpdateDto,
AssetJobsDto,
AssetMetadataResponseDto,
AssetMetadataRouteParams,
AssetMetadataUpsertDto,
AssetStatsDto,
AssetStatsResponseDto,
DeviceIdDto,
@@ -13,7 +16,7 @@ import {
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { RouteKey } from 'src/enum';
import { Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AssetService } from 'src/services/asset.service';
import { UUIDParamDto } from 'src/validation';
@@ -24,7 +27,7 @@ export class AssetController {
constructor(private service: AssetService) {}
@Get('random')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' })
getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
return this.service.getRandom(auth, dto.count ?? 1);
@@ -44,7 +47,7 @@ export class AssetController {
}
@Get('statistics')
@Authenticated()
@Authenticated({ permission: Permission.AssetStatistics })
getAssetStatistics(@Auth() auth: AuthDto, @Query() dto: AssetStatsDto): Promise<AssetStatsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@@ -57,27 +60,27 @@ export class AssetController {
}
@Put()
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
updateAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkDeleteDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Get(':id')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetRead, sharedLink: true })
getAssetInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
return this.service.get(auth, id) as Promise<AssetResponseDto>;
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.AssetUpdate })
updateAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -85,4 +88,36 @@ export class AssetController {
): Promise<AssetResponseDto> {
return this.service.update(auth, id, dto);
}
@Get(':id/metadata')
@Authenticated({ permission: Permission.AssetRead })
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {
return this.service.getMetadata(auth, id);
}
@Put(':id/metadata')
@Authenticated({ permission: Permission.AssetUpdate })
updateAssetMetadata(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetMetadataUpsertDto,
): Promise<AssetMetadataResponseDto[]> {
return this.service.upsertMetadata(auth, id, dto);
}
@Get(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetRead })
getAssetMetadataByKey(
@Auth() auth: AuthDto,
@Param() { id, key }: AssetMetadataRouteParams,
): Promise<AssetMetadataResponseDto> {
return this.service.getMetadataByKey(auth, id, key);
}
@Delete(':id/metadata/:key')
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
return this.service.deleteMetadataByKey(auth, id, key);
}
}
@@ -0,0 +1,18 @@
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AuthAdminService } from 'src/services/auth-admin.service';
@ApiTags('Auth (admin)')
@Controller('admin/auth')
export class AuthAdminController {
constructor(private service: AuthAdminService) {}
@Post('unlink-all')
@Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise<void> {
return this.service.unlinkAll(auth);
}
}
+12 -9
View File
@@ -16,7 +16,7 @@ import {
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { AuthType, ImmichCookie } from 'src/enum';
import { AuthType, ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@@ -49,22 +49,22 @@ export class AuthController {
}
@Post('validateToken')
@Authenticated({ permission: false })
@HttpCode(HttpStatus.OK)
@Authenticated()
validateAccessToken(): ValidateAccessTokenResponseDto {
return { authStatus: true };
}
@Post('change-password')
@Authenticated({ permission: Permission.AuthChangePassword })
@HttpCode(HttpStatus.OK)
@Authenticated()
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
return this.service.changePassword(auth, dto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
async logout(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@@ -87,33 +87,36 @@ export class AuthController {
}
@Post('pin-code')
@Authenticated()
@Authenticated({ permission: Permission.PinCodeCreate })
@HttpCode(HttpStatus.NO_CONTENT)
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.setupPinCode(auth, dto);
}
@Put('pin-code')
@Authenticated()
@Authenticated({ permission: Permission.PinCodeUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.changePinCode(auth, dto);
}
@Delete('pin-code')
@Authenticated()
@Authenticated({ permission: Permission.PinCodeDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
@Post('session/unlock')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.unlockSession(auth, dto);
}
@Post('session/lock')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
async lockAuthSession(@Auth() auth: AuthDto): Promise<void> {
return this.service.lockSession(auth);
}
@@ -1,9 +1,9 @@
import { Readable } from 'node:stream';
import { DownloadController } from 'src/controllers/download.controller';
import { DownloadService } from 'src/services/download.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
import { Readable } from 'typeorm/platform/PlatformTools.js';
describe(DownloadController.name, () => {
let ctx: ControllerContext;
@@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { DownloadService } from 'src/services/download.service';
import { asStreamableFile } from 'src/utils/file';
@@ -13,15 +14,15 @@ export class DownloadController {
constructor(private service: DownloadService) {}
@Post('info')
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
getDownloadInfo(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {
return this.service.getDownloadInfo(auth, dto);
}
@Post('archive')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
@FileResponse()
@Authenticated({ sharedLink: true })
@HttpCode(HttpStatus.OK)
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
}
@@ -1,8 +1,9 @@
import { Body, Controller, Delete, Get, Param } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { DuplicateService } from 'src/services/duplicate.service';
import { UUIDParamDto } from 'src/validation';
@@ -13,19 +14,21 @@ export class DuplicateController {
constructor(private service: DuplicateService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.DuplicateRead })
getAssetDuplicates(@Auth() auth: AuthDto): Promise<DuplicateResponseDto[]> {
return this.service.getDuplicates(auth);
}
@Delete()
@Authenticated()
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteDuplicates(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -42,7 +42,8 @@ export class FaceController {
@Delete(':id')
@Authenticated({ permission: Permission.FaceDelete })
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) {
@HttpCode(HttpStatus.NO_CONTENT)
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto): Promise<void> {
return this.service.deleteFace(auth, id, dto);
}
}
+4 -2
View File
@@ -1,9 +1,10 @@
import { ActivityController } from 'src/controllers/activity.controller';
import { AlbumController } from 'src/controllers/album.controller';
import { APIKeyController } from 'src/controllers/api-key.controller';
import { ApiKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
@@ -33,13 +34,14 @@ import { UserController } from 'src/controllers/user.controller';
import { ViewController } from 'src/controllers/view.controller';
export const controllers = [
APIKeyController,
ApiKeyController,
ActivityController,
AlbumController,
AppController,
AssetController,
AssetMediaController,
AuthController,
AuthAdminController,
DownloadController,
DuplicateController,
FaceController,
+6 -4
View File
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { JobService } from 'src/services/job.service';
@@ -10,19 +11,20 @@ export class JobController {
constructor(private service: JobService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.JobRead, admin: true })
getAllJobsStatus(): Promise<AllJobStatusResponseDto> {
return this.service.getAllJobsStatus();
}
@Post()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.JobCreate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
createJob(@Body() dto: JobCreateDto): Promise<void> {
return this.service.create(dto);
}
@Put(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.JobCreate, admin: true })
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
return this.service.handleCommand(id, dto);
}
+4 -4
View File
@@ -43,15 +43,15 @@ export class LibraryController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.LibraryDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(id);
}
@Post(':id/validate')
@HttpCode(200)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
// TODO: change endpoint to validate current settings instead
validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
return this.service.validate(id, dto);
@@ -64,9 +64,9 @@ export class LibraryController {
}
@Post(':id/scan')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.LibraryUpdate, admin: true })
scanLibrary(@Param() { id }: UUIDParamDto) {
@HttpCode(HttpStatus.NO_CONTENT)
scanLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.queueScan(id);
}
}
+4 -4
View File
@@ -32,7 +32,7 @@ export class MemoryController {
}
@Get('statistics')
@Authenticated({ permission: Permission.MemoryRead })
@Authenticated({ permission: Permission.MemoryStatistics })
memoriesStatistics(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryStatisticsResponseDto> {
return this.service.statistics(auth, dto);
}
@@ -54,14 +54,14 @@ export class MemoryController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.MemoryDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@Put(':id/assets')
@Authenticated()
@Authenticated({ permission: Permission.MemoryAssetCreate })
addMemoryAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -71,8 +71,8 @@ export class MemoryController {
}
@Delete(':id/assets')
@Authenticated({ permission: Permission.MemoryAssetDelete })
@HttpCode(HttpStatus.OK)
@Authenticated()
removeMemoryAssets(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,
@@ -25,15 +25,15 @@ export class NotificationAdminController {
}
@Post('test-email')
@HttpCode(HttpStatus.OK)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
sendTestEmailAdmin(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto);
}
@Post('templates/:name')
@HttpCode(HttpStatus.OK)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
getNotificationTemplateAdmin(
@Auth() auth: AuthDto,
@Param('name') name: EmailTemplate,
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -26,12 +26,14 @@ export class NotificationController {
@Put()
@Authenticated({ permission: Permission.NotificationUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -54,6 +56,7 @@ export class NotificationController {
@Delete(':id')
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
+2 -1
View File
@@ -70,6 +70,7 @@ export class OAuthController {
@Post('link')
@Authenticated()
@HttpCode(HttpStatus.OK)
linkOAuthAccount(
@Req() request: Request,
@Auth() auth: AuthDto,
@@ -79,8 +80,8 @@ export class OAuthController {
}
@Post('unlink')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth);
}
@@ -0,0 +1,101 @@
import { PartnerController } from 'src/controllers/partner.controller';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerService } from 'src/services/partner.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(PartnerController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(PartnerService);
beforeAll(async () => {
ctx = await controllerSetup(PartnerController, [
{ provide: PartnerService, useValue: service },
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /partners', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/partners');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require a direction`, async () => {
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'direction should not be empty',
expect.stringContaining('direction must be one of the following values:'),
]),
);
});
it(`should require direction to be an enum`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/partners`)
.query({ direction: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([expect.stringContaining('direction must be one of the following values:')]),
);
});
});
describe('POST /partners', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/partners');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require sharedWithId to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.post(`/partners`)
.send({ sharedWithId: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('PUT /partners/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/partners/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/partners/invalid`)
.send({ inTimeline: true })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('DELETE /partners/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete(`/partners/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.delete(`/partners/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
});
+15 -6
View File
@@ -1,7 +1,8 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { EndpointLifecycle } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PartnerService } from 'src/services/partner.service';
@@ -18,10 +19,17 @@ export class PartnerController {
return this.service.search(auth, dto);
}
@Post(':id')
@Post()
@Authenticated({ permission: Permission.PartnerCreate })
createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, id);
createPartner(@Auth() auth: AuthDto, @Body() dto: PartnerCreateDto): Promise<PartnerResponseDto> {
return this.service.create(auth, dto);
}
@Post(':id')
@EndpointLifecycle({ deprecatedAt: 'v1.141.0' })
@Authenticated({ permission: Permission.PartnerCreate })
createPartnerDeprecated(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, { sharedWithId: id });
}
@Put(':id')
@@ -29,13 +37,14 @@ export class PartnerController {
updatePartner(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdatePartnerDto,
@Body() dto: PartnerUpdateDto,
): Promise<PartnerResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.PartnerDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
+3 -2
View File
@@ -63,8 +63,8 @@ export class PersonController {
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -86,8 +86,8 @@ export class PersonController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -122,6 +122,7 @@ export class PersonController {
@Post(':id/merge')
@Authenticated({ permission: Permission.PersonMerge })
@HttpCode(HttpStatus.OK)
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -128,12 +128,6 @@ describe(SearchController.name, () => {
await request(ctx.getHttpServer()).post('/search/smart');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a query', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string']));
});
});
describe('GET /search/explore', () => {
+19 -10
View File
@@ -4,6 +4,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto';
import {
LargeAssetSearchDto,
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
@@ -16,6 +17,7 @@ import {
SmartSearchDto,
StatisticsSearchDto,
} from 'src/dtos/search.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SearchService } from 'src/services/search.service';
@@ -25,59 +27,66 @@ export class SearchController {
constructor(private service: SearchService) {}
@Post('metadata')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchAssets(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise<SearchResponseDto> {
return this.service.searchMetadata(auth, dto);
}
@Post('statistics')
@Authenticated({ permission: Permission.AssetStatistics })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchAssetStatistics(@Auth() auth: AuthDto, @Body() dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
return this.service.searchStatistics(auth, dto);
}
@Post('random')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchRandom(auth, dto);
}
@Post('smart')
@Post('large-assets')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchLargeAssets(auth, dto);
}
@Post('smart')
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
@Authenticated()
searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise<SearchResponseDto> {
return this.service.searchSmart(auth, dto);
}
@Get('explore')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
return this.service.getExploreData(auth);
}
@Get('person')
@Authenticated()
@Authenticated({ permission: Permission.PersonRead })
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.service.searchPerson(auth, dto);
}
@Get('places')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
searchPlaces(@Query() dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {
return this.service.searchPlaces(dto);
}
@Get('cities')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
getAssetsByCity(@Auth() auth: AuthDto): Promise<AssetResponseDto[]> {
return this.service.getAssetsByCity(auth);
}
@Get('suggestions')
@Authenticated()
@Authenticated({ permission: Permission.AssetRead })
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
// TODO fix open api generation to indicate that results can be nullable
return this.service.getSearchSuggestions(auth, dto) as Promise<string[]>;
+21 -19
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Put } from '@nestjs/common';
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
@@ -15,6 +15,7 @@ import {
ServerVersionResponseDto,
} from 'src/dtos/server.dto';
import { VersionCheckStateResponseDto } from 'src/dtos/system-metadata.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { ServerService } from 'src/services/server.service';
import { SystemMetadataService } from 'src/services/system-metadata.service';
@@ -30,19 +31,19 @@ export class ServerController {
) {}
@Get('about')
@Authenticated()
@Authenticated({ permission: Permission.ServerAbout })
getAboutInfo(): Promise<ServerAboutResponseDto> {
return this.service.getAboutInfo();
}
@Get('apk-links')
@Authenticated()
@Authenticated({ permission: Permission.ServerApkLinks })
getApkLinks(): ServerApkLinksDto {
return this.service.getApkLinks();
}
@Get('storage')
@Authenticated()
@Authenticated({ permission: Permission.ServerStorage })
getStorage(): Promise<ServerStorageResponseDto> {
return this.service.getStorage();
}
@@ -78,7 +79,7 @@ export class ServerController {
}
@Get('statistics')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ServerStatistics, admin: true })
getServerStatistics(): Promise<ServerStatsResponseDto> {
return this.service.getStatistics();
}
@@ -88,27 +89,28 @@ export class ServerController {
return this.service.getSupportedMediaTypes();
}
@Put('license')
@Authenticated({ admin: true })
setServerLicense(@Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(license);
}
@Delete('license')
@Authenticated({ admin: true })
deleteServerLicense(): Promise<void> {
return this.service.deleteLicense();
}
@Get('license')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ServerLicenseRead, admin: true })
@ApiNotFoundResponse()
getServerLicense(): Promise<LicenseResponseDto> {
return this.service.getLicense();
}
@Put('license')
@Authenticated({ permission: Permission.ServerLicenseUpdate, admin: true })
setServerLicense(@Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(license);
}
@Delete('license')
@Authenticated({ permission: Permission.ServerLicenseDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
deleteServerLicense(): Promise<void> {
return this.service.deleteLicense();
}
@Get('version-check')
@Authenticated()
@Authenticated({ permission: Permission.ServerVersionCheck })
getVersionCheck(): Promise<VersionCheckStateResponseDto> {
return this.systemMetadataService.getVersionCheckState();
}
@@ -1,4 +1,18 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -73,6 +87,7 @@ export class SharedLinkController {
@Delete(':id')
@Authenticated({ permission: Permission.SharedLinkDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
+9 -2
View File
@@ -6,7 +6,7 @@ import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto } from
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { StackService } from 'src/services/stack.service';
import { UUIDParamDto } from 'src/validation';
import { UUIDAssetIDParamDto, UUIDParamDto } from 'src/validation';
@ApiTags('Stacks')
@Controller('stacks')
@@ -49,9 +49,16 @@ export class StackController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.StackDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@Delete(':id/assets/:assetId')
@Authenticated({ permission: Permission.StackUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
removeAssetFromStack(@Auth() auth: AuthDto, @Param() dto: UUIDAssetIDParamDto): Promise<void> {
return this.service.removeAsset(auth, dto);
}
}
+8 -7
View File
@@ -12,6 +12,7 @@ import {
SyncAckSetDto,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { SyncService } from 'src/services/sync.service';
@@ -25,23 +26,23 @@ export class SyncController {
) {}
@Post('full-sync')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getFullSync(auth, dto);
}
@Post('delta-sync')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getDeltaSync(auth, dto);
}
@Post('stream')
@Authenticated({ permission: Permission.SyncStream })
@Header('Content-Type', 'application/jsonlines+json')
@HttpCode(HttpStatus.OK)
@Authenticated()
async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) {
try {
await this.service.stream(auth, res, dto);
@@ -52,22 +53,22 @@ export class SyncController {
}
@Get('ack')
@Authenticated()
@Authenticated({ permission: Permission.SyncCheckpointRead })
getSyncAck(@Auth() auth: AuthDto): Promise<SyncAckDto[]> {
return this.service.getAcks(auth);
}
@Post('ack')
@Authenticated({ permission: Permission.SyncCheckpointUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
sendSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckSetDto) {
return this.service.setAcks(auth, dto);
}
@Delete('ack')
@Authenticated({ permission: Permission.SyncCheckpointDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto) {
deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto): Promise<void> {
return this.service.deleteAcks(auth, dto);
}
}
@@ -21,8 +21,8 @@ export class SystemMetadataController {
}
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.SystemMetadataUpdate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}
+1 -1
View File
@@ -57,8 +57,8 @@ export class TagController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.TagDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
+3 -3
View File
@@ -13,22 +13,22 @@ export class TrashController {
constructor(private service: TrashService) {}
@Post('empty')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
emptyTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.empty(auth);
}
@Post('restore')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
restoreTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.restore(auth);
}
@Post('restore/assets')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<TrashResponseDto> {
return this.service.restoreAssets(auth, dto);
}
@@ -0,0 +1,79 @@
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserAdminCreateDto } from 'src/dtos/user.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { UserAdminService } from 'src/services/user-admin.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(UserAdminController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(UserAdminService);
beforeAll(async () => {
ctx = await controllerSetup(UserAdminController, [
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
{ provide: UserAdminService, useValue: service },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /admin/users', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/admin/users');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const dto: UserAdminCreateDto = {
email: 'user@immich.app',
password: 'test',
name: 'Test User',
quotaSizeInBytes: 1.2,
};
const { status, body } = await request(ctx.getHttpServer())
.post(`/admin/users`)
.set('Authorization', `Bearer token`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
describe('GET /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('PUT /admin/users/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should not allow decimal quota`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/admin/users/${factory.uuid()}`)
.set('Authorization', `Bearer token`)
.send({ quotaSizeInBytes: 1.2 });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
});
});
});
+19 -17
View File
@@ -21,7 +21,7 @@ import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { RouteKey } from 'src/enum';
import { Permission, RouteKey } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -38,31 +38,31 @@ export class UserController {
) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.UserRead })
searchUsers(@Auth() auth: AuthDto): Promise<UserResponseDto[]> {
return this.service.search(auth);
}
@Get('me')
@Authenticated()
@Authenticated({ permission: Permission.UserRead })
getMyUser(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.getMe(auth);
}
@Put('me')
@Authenticated()
@Authenticated({ permission: Permission.UserUpdate })
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
return this.service.updateMe(auth, dto);
}
@Get('me/preferences')
@Authenticated()
@Authenticated({ permission: Permission.UserPreferenceRead })
getMyPreferences(@Auth() auth: AuthDto): Promise<UserPreferencesResponseDto> {
return this.service.getMyPreferences(auth);
}
@Put('me/preferences')
@Authenticated()
@Authenticated({ permission: Permission.UserPreferenceUpdate })
updateMyPreferences(
@Auth() auth: AuthDto,
@Body() dto: UserPreferencesUpdateDto,
@@ -71,52 +71,54 @@ export class UserController {
}
@Get('me/license')
@Authenticated()
@Authenticated({ permission: Permission.UserLicenseRead })
getUserLicense(@Auth() auth: AuthDto): Promise<LicenseResponseDto> {
return this.service.getLicense(auth);
}
@Put('me/license')
@Authenticated()
@Authenticated({ permission: Permission.UserLicenseUpdate })
async setUserLicense(@Auth() auth: AuthDto, @Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(auth, license);
}
@Delete('me/license')
@Authenticated()
@Authenticated({ permission: Permission.UserLicenseDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteUserLicense(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteLicense(auth);
}
@Get('me/onboarding')
@Authenticated()
@Authenticated({ permission: Permission.UserOnboardingRead })
getUserOnboarding(@Auth() auth: AuthDto): Promise<OnboardingResponseDto> {
return this.service.getOnboarding(auth);
}
@Put('me/onboarding')
@Authenticated()
@Authenticated({ permission: Permission.UserOnboardingUpdate })
async setUserOnboarding(@Auth() auth: AuthDto, @Body() Onboarding: OnboardingDto): Promise<OnboardingResponseDto> {
return this.service.setOnboarding(auth, Onboarding);
}
@Delete('me/onboarding')
@Authenticated()
@Authenticated({ permission: Permission.UserOnboardingDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteUserOnboarding(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteOnboarding(auth);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.UserRead })
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.get(id);
}
@Post('profile-image')
@Authenticated({ permission: Permission.UserProfileImageUpdate })
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@Post('profile-image')
@Authenticated()
createProfileImage(
@Auth() auth: AuthDto,
@UploadedFile() fileInfo: Express.Multer.File,
@@ -125,15 +127,15 @@ export class UserController {
}
@Delete('profile-image')
@Authenticated({ permission: Permission.UserProfileImageDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}
@Get(':id/profile-image')
@FileResponse()
@Authenticated()
@Authenticated({ permission: Permission.UserProfileImageRead })
async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
await sendFile(res, next, () => this.service.getProfileImage(id), this.logger);
}