1
0
mirror of https://github.com/immich-app/immich.git synced 2025-08-09 23:17:29 +02:00

Refactor web to use OpenAPI SDK (#326)

* Refactor main index page

* Refactor admin page

* Refactor Auth endpoint

* Refactor directory to prep for monorepo

* Fixed refactoring path

* Resolved file path in vite

* Refactor photo index page

* Refactor thumbnail

* Fixed test

* Refactor Video Viewer component

* Refactor download file

* Refactor navigation bar

* Refactor upload file check

* Simplify Upload Asset signature

* PR feedback
This commit is contained in:
Alex
2022-07-10 21:41:45 -05:00
committed by GitHub
parent 7f236c5b18
commit 9a6dfacf9b
55 changed files with 516 additions and 691 deletions

View File

@@ -8,7 +8,6 @@ import {
Get,
Param,
ValidationPipe,
StreamableFile,
Query,
Response,
Headers,
@@ -16,13 +15,13 @@ import {
Logger,
HttpCode,
BadRequestException,
UploadedFile,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { FileFieldsInterceptor, FileInterceptor } from '@nestjs/platform-express';
import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { ServeFileDto } from './dto/serve-file.dto';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { Response as Res } from 'express';
@@ -36,10 +35,14 @@ import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@@ -56,46 +59,43 @@ export class AssetController {
) {}
@Post('upload')
@UseInterceptors(
FileFieldsInterceptor(
[
{ name: 'assetData', maxCount: 1 },
{ name: 'thumbnailData', maxCount: 1 },
],
assetUploadOption,
),
)
@UseInterceptors(FileInterceptor('assetData', assetUploadOption))
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'Asset Upload Information',
type: AssetFileUploadDto,
})
async uploadFile(
@GetAuthUser() authUser: AuthUserDto,
@UploadedFiles() uploadFiles: { assetData: Express.Multer.File[] },
@UploadedFile() file: Express.Multer.File,
@Body(ValidationPipe) assetInfo: CreateAssetDto,
): Promise<'ok' | undefined> {
for (const file of uploadFiles.assetData) {
try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
): Promise<AssetFileUploadResponseDto> {
try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
if (savedAsset) {
await this.assetUploadedQueue.add(
assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
{ jobId: savedAsset.id },
);
}
} catch (e) {
Logger.error(`Error uploading file ${e}`);
throw new BadRequestException(`Error uploading file`, `${e}`);
if (!savedAsset) {
throw new BadRequestException('Asset not created');
}
}
return 'ok';
await this.assetUploadedQueue.add(
assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
{ jobId: savedAsset.id },
);
return new AssetFileUploadResponseDto(savedAsset.id);
} catch (e) {
Logger.error(`Error uploading file ${e}`);
throw new BadRequestException(`Error uploading file`, `${e}`);
}
}
@Get('/download')
async downloadFile(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Query(ValidationPipe) query: ServeFileDto,
): Promise<StreamableFile> {
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
): Promise<any> {
return this.assetService.downloadFile(query, res);
}
@@ -104,14 +104,14 @@ export class AssetController {
@Headers() headers: Record<string, string>,
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Query(ValidationPipe) query: ServeFileDto,
): Promise<StreamableFile | undefined> {
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
): Promise<any> {
return this.assetService.serveFile(authUser, query, res, headers);
}
@Get('/thumbnail/:assetId')
async getAssetThumbnail(@Param('assetId') assetId: string) {
return await this.assetService.getAssetThumbnail(assetId);
async getAssetThumbnail(@Param('assetId') assetId: string): Promise<any> {
return this.assetService.getAssetThumbnail(assetId);
}
@Get('/allObjects')
@@ -195,11 +195,9 @@ export class AssetController {
async checkDuplicateAsset(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) checkDuplicateAssetDto: CheckDuplicateAssetDto,
) {
): Promise<CheckDuplicateAssetResponseDto> {
const res = await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
return {
isExist: res,
};
return new CheckDuplicateAssetResponseDto(res);
}
}

View File

@@ -9,7 +9,6 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
import { constants, createReadStream, ReadStream, stat } from 'fs';
import { ServeFileDto } from './dto/serve-file.dto';
@@ -21,6 +20,8 @@ import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { AssetResponseDto, mapAsset } from './response-dto/asset-response.dto';
import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
const fileInfo = promisify(stat);
@@ -132,8 +133,10 @@ export class AssetService {
let fileReadStream = null;
const asset = await this.findAssetOfDevice(query.did, query.aid);
if (query.isThumb === 'false' || !query.isThumb) {
// Download Video
if (asset.type === AssetType.VIDEO) {
const { size } = await fileInfo(asset.originalPath);
res.set({
'Content-Type': asset.mimeType,
'Content-Length': size,
@@ -142,22 +145,43 @@ export class AssetService {
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
const { size } = await fileInfo(asset.resizePath);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': size,
});
// Download Image
if (!query.isThumb) {
/**
* Download Image Original File
*/
const { size } = await fileInfo(asset.originalPath);
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
res.set({
'Content-Type': asset.mimeType,
'Content-Length': size,
});
await fs.access(asset.originalPath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.originalPath);
} else {
/**
* Download Image Resize File
*/
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
}
const { size } = await fileInfo(asset.resizePath);
res.set({
'Content-Type': 'image/jpeg',
'Content-Length': size,
});
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
fileReadStream = createReadStream(asset.resizePath);
}
}
return new StreamableFile(fileReadStream);
} catch (e) {
Logger.error(`Error download asset`, 'downloadFile');
Logger.error(`Error download asset ${e}`, 'downloadFile');
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
}
}
@@ -177,7 +201,7 @@ export class AssetService {
fileReadStream = createReadStream(asset.webpPath);
} else {
if (!asset.resizePath) {
return new NotFoundException('resizePath not set');
throw new NotFoundException('resizePath not set');
}
await fs.access(asset.resizePath, constants.R_OK | constants.W_OK);
@@ -203,7 +227,7 @@ export class AssetService {
}
// Handle Sending Images
if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
if (asset.type == AssetType.IMAGE) {
try {
/**
* Serve file viewer on the web
@@ -225,7 +249,7 @@ export class AssetService {
/**
* Serve thumbnail image for both web and mobile app
*/
if (query.isThumb === 'false' || !query.isThumb) {
if (!query.isThumb) {
res.set({
'Content-Type': asset.mimeType,
});
@@ -262,7 +286,7 @@ export class AssetService {
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
);
}
} else if (asset.type == AssetType.VIDEO) {
} else {
try {
// Handle Video
let videoPath = asset.originalPath;

View File

@@ -0,0 +1,10 @@
import { AssetType } from '@app/database/entities/asset.entity';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
import { CreateAssetDto } from './create-asset.dto';
export class AssetFileUploadDto {
@IsNotEmpty()
@ApiProperty({ type: 'string', format: 'binary' })
assetData!: any;
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsBooleanString, IsNotEmpty, IsOptional } from 'class-validator';
export class ServeFileDto {
@IsNotEmpty()
@@ -11,10 +12,28 @@ export class ServeFileDto {
did!: string;
@IsOptional()
@IsBooleanString()
isThumb?: string;
@IsBoolean()
@Transform(({ value }) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
return false;
}
return value;
})
@ApiProperty({ type: Boolean, title: 'Is serve thumbnail (resize) file' })
isThumb?: boolean;
@IsOptional()
@IsBooleanString()
isWeb?: string;
@IsBoolean()
@Transform(({ value }) => {
if (value == 'true') {
return true;
} else if (value == 'false') {
return false;
}
return value;
})
@ApiProperty({ type: Boolean, title: 'Is request made from web' })
isWeb?: boolean;
}

View File

@@ -0,0 +1,7 @@
export class AssetFileUploadResponseDto {
constructor(id: string) {
this.id = id;
}
id: string;
}

View File

@@ -0,0 +1,6 @@
export class CheckDuplicateAssetResponseDto {
constructor(isExist: boolean) {
this.isExist = isExist;
}
isExist: boolean;
}

View File

@@ -12,6 +12,7 @@ import {
UploadedFile,
Response,
StreamableFile,
ParseBoolPipe,
} from '@nestjs/common';
import { UserService } from './user.service';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
@@ -24,7 +25,6 @@ import { profileImageUploadOption } from '../../config/profile-image-upload.conf
import { Response as Res } from 'express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { UserResponseDto } from './response-dto/user-response.dto';
import { UserEntity } from '@app/database/entities/user.entity';
import { UserCountResponseDto } from './response-dto/user-count-response.dto';
import { CreateProfileImageDto } from './dto/create-profile-image.dto';
import { CreateProfileImageResponseDto } from './response-dto/create-profile-image-response.dto';
@@ -37,7 +37,10 @@ export class UserController {
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get()
async getAllUsers(@GetAuthUser() authUser: AuthUserDto, @Query('isAll') isAll: boolean): Promise<UserResponseDto[]> {
async getAllUsers(
@GetAuthUser() authUser: AuthUserDto,
@Query('isAll', ParseBoolPipe) isAll: boolean,
): Promise<UserResponseDto[]> {
return await this.userService.getAllUsers(authUser, isAll);
}
@@ -57,8 +60,8 @@ export class UserController {
}
@Get('/count')
async getUserCount(@Query('isAdmin') isAdmin: boolean): Promise<UserCountResponseDto> {
return await this.userService.getUserCount(isAdmin);
async getUserCount(): Promise<UserCountResponseDto> {
return await this.userService.getUserCount();
}
@UseGuards(JwtAuthGuard)
@@ -84,10 +87,7 @@ export class UserController {
}
@Get('/profile-image/:userId')
async getProfileImage(
@Param('userId') userId: string,
@Response({ passthrough: true }) res: Res,
): Promise<StreamableFile | undefined> {
async getProfileImage(@Param('userId') userId: string, @Response({ passthrough: true }) res: Res): Promise<any> {
return this.userService.getUserProfileImage(userId, res);
}
}

View File

@@ -32,7 +32,6 @@ export class UserService {
async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
if (isAll) {
const allUsers = await this.userRepository.find();
return allUsers.map(mapUser);
}
@@ -54,14 +53,8 @@ export class UserService {
return mapUser(user);
}
async getUserCount(isAdmin: boolean): Promise<UserCountResponseDto> {
let users;
if (isAdmin) {
users = await this.userRepository.find({ where: { isAdmin: true } });
} else {
users = await this.userRepository.find();
}
async getUserCount(): Promise<UserCountResponseDto> {
const users = await this.userRepository.find();
return mapUserCountResponse(users.length);
}
@@ -157,8 +150,7 @@ export class UserService {
}
if (!user.profileImagePath) {
res.status(404).send('User does not have a profile image');
return;
throw new NotFoundException('User does not have a profile image');
}
res.set({
@@ -167,7 +159,7 @@ export class UserService {
const fileStream = createReadStream(user.profileImagePath);
return new StreamableFile(fileStream);
} catch (e) {
res.status(404).send('User does not have a profile image');
throw new NotFoundException('User does not have a profile image');
}
}
}

View File

@@ -15,6 +15,7 @@ import { AppController } from './app.controller';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { DatabaseModule } from '@app/database';
import { AppLoggerMiddleware } from './middlewares/app-logger.middleware';
@Module({
imports: [

View File

@@ -86,7 +86,7 @@ describe('User', () => {
});
it('fetches the user collection excluding the auth user', async () => {
const { status, body } = await request(app.getHttpServer()).get('/user');
const { status, body } = await request(app.getHttpServer()).get('/user?isAll=false');
expect(status).toEqual(200);
expect(body).toHaveLength(2);
expect(body).toEqual(