You've already forked immich
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
@@ -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;
|
||||
}
|
||||
|
@@ -0,0 +1,7 @@
|
||||
export class AssetFileUploadResponseDto {
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
id: string;
|
||||
}
|
@@ -0,0 +1,6 @@
|
||||
export class CheckDuplicateAssetResponseDto {
|
||||
constructor(isExist: boolean) {
|
||||
this.isExist = isExist;
|
||||
}
|
||||
isExist: boolean;
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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: [
|
||||
|
@@ -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(
|
||||
|
Reference in New Issue
Block a user