mirror of
https://github.com/immich-app/immich.git
synced 2025-01-02 12:48:35 +02:00
refactor: asset v1, app.utils (#8152)
This commit is contained in:
parent
87ccba7f9d
commit
382b63954c
@ -156,10 +156,10 @@
|
|||||||
"coverageDirectory": "./coverage",
|
"coverageDirectory": "./coverage",
|
||||||
"coverageThreshold": {
|
"coverageThreshold": {
|
||||||
"./src/": {
|
"./src/": {
|
||||||
"branches": 75,
|
"branches": 70,
|
||||||
"functions": 80,
|
"functions": 75,
|
||||||
"lines": 85,
|
"lines": 80,
|
||||||
"statements": 85
|
"statements": 80
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
@ -6,12 +6,11 @@ import { existsSync } from 'node:fs';
|
|||||||
import sirv from 'sirv';
|
import sirv from 'sirv';
|
||||||
import { ApiModule } from 'src/apps/api.module';
|
import { ApiModule } from 'src/apps/api.module';
|
||||||
import { ApiService } from 'src/apps/api.service';
|
import { ApiService } from 'src/apps/api.service';
|
||||||
import { excludePaths } from 'src/config';
|
import { WEB_ROOT, envName, excludePaths, isDev, serverVersion } from 'src/constants';
|
||||||
import { WEB_ROOT, envName, isDev, serverVersion } from 'src/constants';
|
|
||||||
import { useSwagger } from 'src/immich/app.utils';
|
|
||||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||||
import { otelSDK } from 'src/utils/instrumentation';
|
import { otelSDK } from 'src/utils/instrumentation';
|
||||||
import { ImmichLogger } from 'src/utils/logger';
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
|
import { useSwagger } from 'src/utils/misc';
|
||||||
|
|
||||||
const logger = new ImmichLogger('ImmichServer');
|
const logger = new ImmichLogger('ImmichServer');
|
||||||
const port = Number(process.env.SERVER_PORT) || 3001;
|
const port = Number(process.env.SERVER_PORT) || 3001;
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common';
|
import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common';
|
||||||
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { ApiService } from 'src/apps/api.service';
|
import { ApiService } from 'src/apps/api.service';
|
||||||
import { AppModule } from 'src/apps/app.module';
|
import { AppModule } from 'src/apps/app.module';
|
||||||
import { ActivityController } from 'src/controllers/activity.controller';
|
import { ActivityController } from 'src/controllers/activity.controller';
|
||||||
import { AlbumController } from 'src/controllers/album.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 { AppController } from 'src/controllers/app.controller';
|
||||||
|
import { AssetControllerV1 } from 'src/controllers/asset-v1.controller';
|
||||||
import { AssetController, AssetsController } from 'src/controllers/asset.controller';
|
import { AssetController, AssetsController } from 'src/controllers/asset.controller';
|
||||||
import { AuditController } from 'src/controllers/audit.controller';
|
import { AuditController } from 'src/controllers/audit.controller';
|
||||||
import { AuthController } from 'src/controllers/auth.controller';
|
import { AuthController } from 'src/controllers/auth.controller';
|
||||||
@ -25,11 +25,6 @@ import { SystemConfigController } from 'src/controllers/system-config.controller
|
|||||||
import { TagController } from 'src/controllers/tag.controller';
|
import { TagController } from 'src/controllers/tag.controller';
|
||||||
import { TrashController } from 'src/controllers/trash.controller';
|
import { TrashController } from 'src/controllers/trash.controller';
|
||||||
import { UserController } from 'src/controllers/user.controller';
|
import { UserController } from 'src/controllers/user.controller';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
|
||||||
import { AssetRepositoryV1, IAssetRepositoryV1 } from 'src/immich/api-v1/asset/asset-repository';
|
|
||||||
import { AssetController as AssetControllerV1 } from 'src/immich/api-v1/asset/asset.controller';
|
|
||||||
import { AssetService as AssetServiceV1 } from 'src/immich/api-v1/asset/asset.service';
|
|
||||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||||
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
||||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||||
@ -39,7 +34,6 @@ import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
|||||||
//
|
//
|
||||||
AppModule,
|
AppModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
|
|
||||||
],
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
ActivityController,
|
ActivityController,
|
||||||
@ -67,19 +61,17 @@ import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
|||||||
PersonController,
|
PersonController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
ApiService,
|
||||||
|
FileUploadInterceptor,
|
||||||
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
|
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
|
||||||
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
|
||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
|
|
||||||
ApiService,
|
|
||||||
AssetServiceV1,
|
|
||||||
FileUploadInterceptor,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ApiModule implements OnModuleInit {
|
export class ApiModule implements OnModuleInit {
|
||||||
constructor(private appService: ApiService) {}
|
constructor(private apiService: ApiService) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.appService.init();
|
await this.apiService.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import { IActivityRepository } from 'src/interfaces/activity.interface';
|
|||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||||
|
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { IAuditRepository } from 'src/interfaces/audit.interface';
|
import { IAuditRepository } from 'src/interfaces/audit.interface';
|
||||||
import { ICommunicationRepository } from 'src/interfaces/communication.interface';
|
import { ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||||
@ -40,6 +41,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
|
|||||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||||
import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
|
import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
|
||||||
|
import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||||
import { CommunicationRepository } from 'src/repositories/communication.repository';
|
import { CommunicationRepository } from 'src/repositories/communication.repository';
|
||||||
@ -65,6 +67,7 @@ import { UserRepository } from 'src/repositories/user.repository';
|
|||||||
import { ActivityService } from 'src/services/activity.service';
|
import { ActivityService } from 'src/services/activity.service';
|
||||||
import { AlbumService } from 'src/services/album.service';
|
import { AlbumService } from 'src/services/album.service';
|
||||||
import { APIKeyService } from 'src/services/api-key.service';
|
import { APIKeyService } from 'src/services/api-key.service';
|
||||||
|
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
import { AuditService } from 'src/services/audit.service';
|
import { AuditService } from 'src/services/audit.service';
|
||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
@ -94,6 +97,7 @@ const services: Provider[] = [
|
|||||||
ActivityService,
|
ActivityService,
|
||||||
AlbumService,
|
AlbumService,
|
||||||
AssetService,
|
AssetService,
|
||||||
|
AssetServiceV1,
|
||||||
AuditService,
|
AuditService,
|
||||||
AuthService,
|
AuthService,
|
||||||
DatabaseService,
|
DatabaseService,
|
||||||
@ -122,6 +126,7 @@ const repositories: Provider[] = [
|
|||||||
{ provide: IAccessRepository, useClass: AccessRepository },
|
{ provide: IAccessRepository, useClass: AccessRepository },
|
||||||
{ provide: IAlbumRepository, useClass: AlbumRepository },
|
{ provide: IAlbumRepository, useClass: AlbumRepository },
|
||||||
{ provide: IAssetRepository, useClass: AssetRepository },
|
{ provide: IAssetRepository, useClass: AssetRepository },
|
||||||
|
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
|
||||||
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
|
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
|
||||||
{ provide: IAuditRepository, useClass: AuditRepository },
|
{ provide: IAuditRepository, useClass: AuditRepository },
|
||||||
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
|
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
|
||||||
|
@ -69,5 +69,3 @@ export const bullConfig: QueueOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name }));
|
export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name }));
|
||||||
|
|
||||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
|
||||||
|
@ -35,6 +35,8 @@ export enum AuthType {
|
|||||||
OAUTH = 'oauth',
|
OAUTH = 'oauth',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
||||||
|
|
||||||
export const FACE_THUMBNAIL_SIZE = 250;
|
export const FACE_THUMBNAIL_SIZE = 250;
|
||||||
|
|
||||||
export const supportedYearTokens = ['y', 'yy'];
|
export const supportedYearTokens = ['y', 'yy'];
|
||||||
|
@ -16,22 +16,26 @@ import {
|
|||||||
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||||
import { NextFunction, Response } from 'express';
|
import { NextFunction, Response } from 'express';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
|
import {
|
||||||
|
AssetBulkUploadCheckResponseDto,
|
||||||
|
AssetFileUploadResponseDto,
|
||||||
|
CheckExistingAssetsResponseDto,
|
||||||
|
CuratedLocationsResponseDto,
|
||||||
|
CuratedObjectsResponseDto,
|
||||||
|
} from 'src/dtos/asset-v1-response.dto';
|
||||||
|
import {
|
||||||
|
AssetBulkUploadCheckDto,
|
||||||
|
AssetSearchDto,
|
||||||
|
CheckExistingAssetsDto,
|
||||||
|
CreateAssetDto,
|
||||||
|
GetAssetThumbnailDto,
|
||||||
|
ServeFileDto,
|
||||||
|
} from 'src/dtos/asset-v1.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetService as AssetServiceV1 } from 'src/immich/api-v1/asset/asset.service';
|
|
||||||
import { AssetBulkUploadCheckDto } from 'src/immich/api-v1/asset/dto/asset-check.dto';
|
|
||||||
import { AssetSearchDto } from 'src/immich/api-v1/asset/dto/asset-search.dto';
|
|
||||||
import { CheckExistingAssetsDto } from 'src/immich/api-v1/asset/dto/check-existing-assets.dto';
|
|
||||||
import { CreateAssetDto } from 'src/immich/api-v1/asset/dto/create-asset.dto';
|
|
||||||
import { GetAssetThumbnailDto } from 'src/immich/api-v1/asset/dto/get-asset-thumbnail.dto';
|
|
||||||
import { ServeFileDto } from 'src/immich/api-v1/asset/dto/serve-file.dto';
|
|
||||||
import { AssetBulkUploadCheckResponseDto } from 'src/immich/api-v1/asset/response-dto/asset-check-response.dto';
|
|
||||||
import { AssetFileUploadResponseDto } from 'src/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
|
|
||||||
import { CheckExistingAssetsResponseDto } from 'src/immich/api-v1/asset/response-dto/check-existing-assets-response.dto';
|
|
||||||
import { CuratedLocationsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-locations-response.dto';
|
|
||||||
import { CuratedObjectsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-objects-response.dto';
|
|
||||||
import { sendFile } from 'src/immich/app.utils';
|
|
||||||
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/middleware/auth.guard';
|
||||||
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from 'src/middleware/file-upload.interceptor';
|
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from 'src/middleware/file-upload.interceptor';
|
||||||
|
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||||
|
import { sendFile } from 'src/utils/file';
|
||||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
interface UploadFiles {
|
interface UploadFiles {
|
||||||
@ -43,8 +47,8 @@ interface UploadFiles {
|
|||||||
@ApiTags('Asset')
|
@ApiTags('Asset')
|
||||||
@Controller(Route.ASSET)
|
@Controller(Route.ASSET)
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
export class AssetController {
|
export class AssetControllerV1 {
|
||||||
constructor(private serviceV1: AssetServiceV1) {}
|
constructor(private service: AssetServiceV1) {}
|
||||||
|
|
||||||
@SharedLinkRoute()
|
@SharedLinkRoute()
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@ -73,7 +77,7 @@ export class AssetController {
|
|||||||
sidecarFile = mapToUploadFile(_sidecarFile);
|
sidecarFile = mapToUploadFile(_sidecarFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseDto = await this.serviceV1.uploadFile(auth, dto, file, livePhotoFile, sidecarFile);
|
const responseDto = await this.service.uploadFile(auth, dto, file, livePhotoFile, sidecarFile);
|
||||||
if (responseDto.duplicate) {
|
if (responseDto.duplicate) {
|
||||||
res.status(HttpStatus.OK);
|
res.status(HttpStatus.OK);
|
||||||
}
|
}
|
||||||
@ -91,7 +95,7 @@ export class AssetController {
|
|||||||
@Param() { id }: UUIDParamDto,
|
@Param() { id }: UUIDParamDto,
|
||||||
@Query() dto: ServeFileDto,
|
@Query() dto: ServeFileDto,
|
||||||
) {
|
) {
|
||||||
await sendFile(res, next, () => this.serviceV1.serveFile(auth, id, dto));
|
await sendFile(res, next, () => this.service.serveFile(auth, id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SharedLinkRoute()
|
@SharedLinkRoute()
|
||||||
@ -104,22 +108,22 @@ export class AssetController {
|
|||||||
@Param() { id }: UUIDParamDto,
|
@Param() { id }: UUIDParamDto,
|
||||||
@Query() dto: GetAssetThumbnailDto,
|
@Query() dto: GetAssetThumbnailDto,
|
||||||
) {
|
) {
|
||||||
await sendFile(res, next, () => this.serviceV1.serveThumbnail(auth, id, dto));
|
await sendFile(res, next, () => this.service.serveThumbnail(auth, id, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/curated-objects')
|
@Get('/curated-objects')
|
||||||
getCuratedObjects(@Auth() auth: AuthDto): Promise<CuratedObjectsResponseDto[]> {
|
getCuratedObjects(@Auth() auth: AuthDto): Promise<CuratedObjectsResponseDto[]> {
|
||||||
return this.serviceV1.getCuratedObject(auth);
|
return this.service.getCuratedObject(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/curated-locations')
|
@Get('/curated-locations')
|
||||||
getCuratedLocations(@Auth() auth: AuthDto): Promise<CuratedLocationsResponseDto[]> {
|
getCuratedLocations(@Auth() auth: AuthDto): Promise<CuratedLocationsResponseDto[]> {
|
||||||
return this.serviceV1.getCuratedLocation(auth);
|
return this.service.getCuratedLocation(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/search-terms')
|
@Get('/search-terms')
|
||||||
getAssetSearchTerms(@Auth() auth: AuthDto): Promise<string[]> {
|
getAssetSearchTerms(@Auth() auth: AuthDto): Promise<string[]> {
|
||||||
return this.serviceV1.getAssetSearchTerm(auth);
|
return this.service.getAssetSearchTerm(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -133,7 +137,7 @@ export class AssetController {
|
|||||||
schema: { type: 'string' },
|
schema: { type: 'string' },
|
||||||
})
|
})
|
||||||
getAllAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise<AssetResponseDto[]> {
|
getAllAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise<AssetResponseDto[]> {
|
||||||
return this.serviceV1.getAllAssets(auth, dto);
|
return this.service.getAllAssets(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -145,7 +149,7 @@ export class AssetController {
|
|||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
@Body() dto: CheckExistingAssetsDto,
|
@Body() dto: CheckExistingAssetsDto,
|
||||||
): Promise<CheckExistingAssetsResponseDto> {
|
): Promise<CheckExistingAssetsResponseDto> {
|
||||||
return this.serviceV1.checkExistingAssets(auth, dto);
|
return this.service.checkExistingAssets(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -157,6 +161,6 @@ export class AssetController {
|
|||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
@Body() dto: AssetBulkUploadCheckDto,
|
@Body() dto: AssetBulkUploadCheckDto,
|
||||||
): Promise<AssetBulkUploadCheckResponseDto> {
|
): Promise<AssetBulkUploadCheckResponseDto> {
|
||||||
return this.serviceV1.bulkUploadCheck(auth, dto);
|
return this.service.bulkUploadCheck(auth, dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -4,9 +4,9 @@ import { NextFunction, Response } from 'express';
|
|||||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||||
import { asStreamableFile, sendFile } from 'src/immich/app.utils';
|
|
||||||
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/middleware/auth.guard';
|
||||||
import { DownloadService } from 'src/services/download.service';
|
import { DownloadService } from 'src/services/download.service';
|
||||||
|
import { asStreamableFile, sendFile } from 'src/utils/file';
|
||||||
import { UUIDParamDto } from 'src/validation';
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags('Download')
|
@ApiTags('Download')
|
||||||
|
@ -15,9 +15,9 @@ import {
|
|||||||
PersonStatisticsResponseDto,
|
PersonStatisticsResponseDto,
|
||||||
PersonUpdateDto,
|
PersonUpdateDto,
|
||||||
} from 'src/dtos/person.dto';
|
} from 'src/dtos/person.dto';
|
||||||
import { sendFile } from 'src/immich/app.utils';
|
|
||||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
|
import { sendFile } from 'src/utils/file';
|
||||||
import { UUIDParamDto } from 'src/validation';
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags('Person')
|
@ApiTags('Person')
|
||||||
|
@ -19,10 +19,10 @@ import { NextFunction, Response } from 'express';
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||||
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto } from 'src/dtos/user.dto';
|
import { CreateUserDto, DeleteUserDto, UpdateUserDto, UserResponseDto } from 'src/dtos/user.dto';
|
||||||
import { sendFile } from 'src/immich/app.utils';
|
|
||||||
import { AdminRoute, Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
import { AdminRoute, Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||||
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
|
import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
|
import { sendFile } from 'src/utils/file';
|
||||||
import { UUIDParamDto } from 'src/validation';
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags('User')
|
@ApiTags('User')
|
||||||
|
45
server/src/dtos/asset-v1-response.dto.ts
Normal file
45
server/src/dtos/asset-v1-response.dto.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export class AssetBulkUploadCheckResult {
|
||||||
|
id!: string;
|
||||||
|
action!: AssetUploadAction;
|
||||||
|
reason?: AssetRejectReason;
|
||||||
|
assetId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetBulkUploadCheckResponseDto {
|
||||||
|
results!: AssetBulkUploadCheckResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AssetUploadAction {
|
||||||
|
ACCEPT = 'accept',
|
||||||
|
REJECT = 'reject',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AssetRejectReason {
|
||||||
|
DUPLICATE = 'duplicate',
|
||||||
|
UNSUPPORTED_FORMAT = 'unsupported-format',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetFileUploadResponseDto {
|
||||||
|
id!: string;
|
||||||
|
duplicate!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CheckExistingAssetsResponseDto {
|
||||||
|
existingIds!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CuratedLocationsResponseDto {
|
||||||
|
id!: string;
|
||||||
|
city!: string;
|
||||||
|
resizePath!: string;
|
||||||
|
deviceAssetId!: string;
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CuratedObjectsResponseDto {
|
||||||
|
id!: string;
|
||||||
|
object!: string;
|
||||||
|
resizePath!: string;
|
||||||
|
deviceAssetId!: string;
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
154
server/src/dtos/asset-v1.dto.ts
Normal file
154
server/src/dtos/asset-v1.dto.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ArrayNotEmpty, IsArray, IsEnum, IsInt, IsNotEmpty, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||||
|
import { UploadFieldName } from 'src/dtos/asset.dto';
|
||||||
|
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
|
export class AssetBulkUploadCheckItem {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
/** base64 or hex encoded sha1 hash */
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
checksum!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetBulkUploadCheckDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => AssetBulkUploadCheckItem)
|
||||||
|
assets!: AssetBulkUploadCheckItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AssetSearchDto {
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isFavorite?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isArchived?: boolean;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
skip?: number;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsInt()
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
take?: number;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsUUID('4')
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@ValidateDate({ optional: true })
|
||||||
|
updatedAfter?: Date;
|
||||||
|
|
||||||
|
@ValidateDate({ optional: true })
|
||||||
|
updatedBefore?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CheckExistingAssetsDto {
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@IsNotEmpty({ each: true })
|
||||||
|
deviceAssetIds!: string[];
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateAssetDto {
|
||||||
|
@ValidateUUID({ optional: true })
|
||||||
|
libraryId?: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
deviceAssetId!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
deviceId!: string;
|
||||||
|
|
||||||
|
@ValidateDate()
|
||||||
|
fileCreatedAt!: Date;
|
||||||
|
|
||||||
|
@ValidateDate()
|
||||||
|
fileModifiedAt!: Date;
|
||||||
|
|
||||||
|
@Optional()
|
||||||
|
@IsString()
|
||||||
|
duration?: string;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isFavorite?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isArchived?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isVisible?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isOffline?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
|
||||||
|
// The properties below are added to correctly generate the API docs
|
||||||
|
// and client SDKs. Validation should be handled in the controller.
|
||||||
|
@ApiProperty({ type: 'string', format: 'binary' })
|
||||||
|
[UploadFieldName.ASSET_DATA]!: any;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||||
|
[UploadFieldName.LIVE_PHOTO_DATA]?: any;
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||||
|
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum GetAssetThumbnailFormatEnum {
|
||||||
|
JPEG = 'JPEG',
|
||||||
|
WEBP = 'WEBP',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetAssetThumbnailDto {
|
||||||
|
@Optional()
|
||||||
|
@IsEnum(GetAssetThumbnailFormatEnum)
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
enum: GetAssetThumbnailFormatEnum,
|
||||||
|
default: GetAssetThumbnailFormatEnum.WEBP,
|
||||||
|
required: false,
|
||||||
|
enumName: 'ThumbnailFormat',
|
||||||
|
})
|
||||||
|
format: GetAssetThumbnailFormatEnum = GetAssetThumbnailFormatEnum.WEBP;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchPropertiesDto {
|
||||||
|
tags?: string[];
|
||||||
|
objects?: string[];
|
||||||
|
assetType?: string;
|
||||||
|
orientation?: string;
|
||||||
|
lensModel?: string;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServeFileDto {
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
@ApiProperty({ title: 'Is serve thumbnail (resize) file' })
|
||||||
|
isThumb?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
@ApiProperty({ title: 'Is request made from web' })
|
||||||
|
isWeb?: boolean;
|
||||||
|
}
|
@ -1,20 +0,0 @@
|
|||||||
import { Type } from 'class-transformer';
|
|
||||||
import { IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
|
||||||
|
|
||||||
export class AssetBulkUploadCheckItem {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
/** base64 or hex encoded sha1 hash */
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
checksum!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssetBulkUploadCheckDto {
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => AssetBulkUploadCheckItem)
|
|
||||||
assets!: AssetBulkUploadCheckItem[];
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { IsInt, IsUUID } from 'class-validator';
|
|
||||||
import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
|
|
||||||
|
|
||||||
export class AssetSearchDto {
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isFavorite?: boolean;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isArchived?: boolean;
|
|
||||||
|
|
||||||
@Optional()
|
|
||||||
@IsInt()
|
|
||||||
@Type(() => Number)
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
skip?: number;
|
|
||||||
|
|
||||||
@Optional()
|
|
||||||
@IsInt()
|
|
||||||
@Type(() => Number)
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
take?: number;
|
|
||||||
|
|
||||||
@Optional()
|
|
||||||
@IsUUID('4')
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
|
||||||
userId?: string;
|
|
||||||
|
|
||||||
@ValidateDate({ optional: true })
|
|
||||||
updatedAfter?: Date;
|
|
||||||
|
|
||||||
@ValidateDate({ optional: true })
|
|
||||||
updatedBefore?: Date;
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { validateSync } from 'class-validator';
|
|
||||||
import { CheckExistingAssetsDto } from 'src/immich/api-v1/asset/dto/check-existing-assets.dto';
|
|
||||||
|
|
||||||
describe('CheckExistingAssetsDto', () => {
|
|
||||||
it('should fail with an empty list', () => {
|
|
||||||
const dto = plainToInstance(CheckExistingAssetsDto, { deviceAssetIds: [], deviceId: 'test-device' });
|
|
||||||
const errors = validateSync(dto);
|
|
||||||
expect(errors).toHaveLength(1);
|
|
||||||
expect(errors[0].property).toEqual('deviceAssetIds');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail with an empty string', () => {
|
|
||||||
const dto = plainToInstance(CheckExistingAssetsDto, { deviceAssetIds: [''], deviceId: 'test-device' });
|
|
||||||
const errors = validateSync(dto);
|
|
||||||
expect(errors).toHaveLength(1);
|
|
||||||
expect(errors[0].property).toEqual('deviceAssetIds');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should work with valid asset ids', () => {
|
|
||||||
const dto = plainToInstance(CheckExistingAssetsDto, {
|
|
||||||
deviceAssetIds: ['asset-1', 'asset-2'],
|
|
||||||
deviceId: 'test-device',
|
|
||||||
});
|
|
||||||
const errors = validateSync(dto);
|
|
||||||
expect(errors).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,11 +0,0 @@
|
|||||||
import { ArrayNotEmpty, IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CheckExistingAssetsDto {
|
|
||||||
@ArrayNotEmpty()
|
|
||||||
@IsString({ each: true })
|
|
||||||
@IsNotEmpty({ each: true })
|
|
||||||
deviceAssetIds!: string[];
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
deviceId!: string;
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
import { UploadFieldName } from 'src/dtos/asset.dto';
|
|
||||||
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
|
||||||
|
|
||||||
export class CreateAssetDto {
|
|
||||||
@ValidateUUID({ optional: true })
|
|
||||||
libraryId?: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
deviceAssetId!: string;
|
|
||||||
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
deviceId!: string;
|
|
||||||
|
|
||||||
@ValidateDate()
|
|
||||||
fileCreatedAt!: Date;
|
|
||||||
|
|
||||||
@ValidateDate()
|
|
||||||
fileModifiedAt!: Date;
|
|
||||||
|
|
||||||
@Optional()
|
|
||||||
@IsString()
|
|
||||||
duration?: string;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isFavorite?: boolean;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isArchived?: boolean;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isVisible?: boolean;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isOffline?: boolean;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
isReadOnly?: boolean;
|
|
||||||
|
|
||||||
// The properties below are added to correctly generate the API docs
|
|
||||||
// and client SDKs. Validation should be handled in the controller.
|
|
||||||
@ApiProperty({ type: 'string', format: 'binary' })
|
|
||||||
[UploadFieldName.ASSET_DATA]!: any;
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
|
||||||
[UploadFieldName.LIVE_PHOTO_DATA]?: any;
|
|
||||||
|
|
||||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
|
||||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
|
||||||
}
|
|
@ -1,21 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { IsEnum } from 'class-validator';
|
|
||||||
import { Optional } from 'src/validation';
|
|
||||||
|
|
||||||
export enum GetAssetThumbnailFormatEnum {
|
|
||||||
JPEG = 'JPEG',
|
|
||||||
WEBP = 'WEBP',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetAssetThumbnailDto {
|
|
||||||
@Optional()
|
|
||||||
@IsEnum(GetAssetThumbnailFormatEnum)
|
|
||||||
@ApiProperty({
|
|
||||||
type: String,
|
|
||||||
enum: GetAssetThumbnailFormatEnum,
|
|
||||||
default: GetAssetThumbnailFormatEnum.WEBP,
|
|
||||||
required: false,
|
|
||||||
enumName: 'ThumbnailFormat',
|
|
||||||
})
|
|
||||||
format: GetAssetThumbnailFormatEnum = GetAssetThumbnailFormatEnum.WEBP;
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
export class SearchPropertiesDto {
|
|
||||||
tags?: string[];
|
|
||||||
objects?: string[];
|
|
||||||
assetType?: string;
|
|
||||||
orientation?: string;
|
|
||||||
lensModel?: string;
|
|
||||||
make?: string;
|
|
||||||
model?: string;
|
|
||||||
city?: string;
|
|
||||||
state?: string;
|
|
||||||
country?: string;
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { ValidateBoolean } from 'src/validation';
|
|
||||||
|
|
||||||
export class ServeFileDto {
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
@ApiProperty({ title: 'Is serve thumbnail (resize) file' })
|
|
||||||
isThumb?: boolean;
|
|
||||||
|
|
||||||
@ValidateBoolean({ optional: true })
|
|
||||||
@ApiProperty({ title: 'Is request made from web' })
|
|
||||||
isWeb?: boolean;
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
export class AssetBulkUploadCheckResult {
|
|
||||||
id!: string;
|
|
||||||
action!: AssetUploadAction;
|
|
||||||
reason?: AssetRejectReason;
|
|
||||||
assetId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssetBulkUploadCheckResponseDto {
|
|
||||||
results!: AssetBulkUploadCheckResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AssetUploadAction {
|
|
||||||
ACCEPT = 'accept',
|
|
||||||
REJECT = 'reject',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum AssetRejectReason {
|
|
||||||
DUPLICATE = 'duplicate',
|
|
||||||
UNSUPPORTED_FORMAT = 'unsupported-format',
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export class AssetFileUploadResponseDto {
|
|
||||||
id!: string;
|
|
||||||
duplicate!: boolean;
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
export class CheckExistingAssetsResponseDto {
|
|
||||||
existingIds!: string[];
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
export class CuratedLocationsResponseDto {
|
|
||||||
id!: string;
|
|
||||||
city!: string;
|
|
||||||
resizePath!: string;
|
|
||||||
deviceAssetId!: string;
|
|
||||||
deviceId!: string;
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
export class CuratedObjectsResponseDto {
|
|
||||||
id!: string;
|
|
||||||
object!: string;
|
|
||||||
resizePath!: string;
|
|
||||||
deviceAssetId!: string;
|
|
||||||
deviceId!: string;
|
|
||||||
}
|
|
@ -1,205 +0,0 @@
|
|||||||
import { HttpException, INestApplication, StreamableFile } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
DocumentBuilder,
|
|
||||||
OpenAPIObject,
|
|
||||||
SwaggerCustomOptions,
|
|
||||||
SwaggerDocumentOptions,
|
|
||||||
SwaggerModule,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
|
|
||||||
import { NextFunction, Response } from 'express';
|
|
||||||
import _ from 'lodash';
|
|
||||||
import { writeFileSync } from 'node:fs';
|
|
||||||
import { access, constants } from 'node:fs/promises';
|
|
||||||
import path, { isAbsolute } from 'node:path';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME, serverVersion } from 'src/constants';
|
|
||||||
import { ImmichReadStream } from 'src/interfaces/storage.interface';
|
|
||||||
import { Metadata } from 'src/middleware/auth.guard';
|
|
||||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
|
||||||
import { ImmichLogger } from 'src/utils/logger';
|
|
||||||
import { isConnectionAborted } from 'src/utils/misc';
|
|
||||||
|
|
||||||
type SendFile = Parameters<Response['sendFile']>;
|
|
||||||
type SendFileOptions = SendFile[1];
|
|
||||||
|
|
||||||
const logger = new ImmichLogger('SendFile');
|
|
||||||
|
|
||||||
export const sendFile = async (
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
handler: () => Promise<ImmichFileResponse>,
|
|
||||||
): Promise<void> => {
|
|
||||||
const _sendFile = (path: string, options: SendFileOptions) =>
|
|
||||||
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const file = await handler();
|
|
||||||
switch (file.cacheControl) {
|
|
||||||
case CacheControl.PRIVATE_WITH_CACHE: {
|
|
||||||
res.set('Cache-Control', 'private, max-age=86400, no-transform');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case CacheControl.PRIVATE_WITHOUT_CACHE: {
|
|
||||||
res.set('Cache-Control', 'private, no-cache, no-transform');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.header('Content-Type', file.contentType);
|
|
||||||
|
|
||||||
const options: SendFileOptions = { dotfiles: 'allow' };
|
|
||||||
if (!isAbsolute(file.path)) {
|
|
||||||
options.root = process.cwd();
|
|
||||||
}
|
|
||||||
|
|
||||||
await access(file.path, constants.R_OK);
|
|
||||||
|
|
||||||
return _sendFile(file.path, options);
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
// ignore client-closed connection
|
|
||||||
if (isConnectionAborted(error)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// log non-http errors
|
|
||||||
if (error instanceof HttpException === false) {
|
|
||||||
logger.error(`Unable to send file: ${error.name}`, error.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.header('Cache-Control', 'none');
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
|
||||||
return new StreamableFile(stream, { type, length });
|
|
||||||
};
|
|
||||||
|
|
||||||
function sortKeys<T>(target: T): T {
|
|
||||||
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Partial<T> = {};
|
|
||||||
const keys = Object.keys(target).sort() as Array<keyof T>;
|
|
||||||
for (const key of keys) {
|
|
||||||
result[key] = sortKeys(target[key]);
|
|
||||||
}
|
|
||||||
return result as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const routeToErrorMessage = (methodName: string) =>
|
|
||||||
'Failed to ' + methodName.replaceAll(/[A-Z]+/g, (letter) => ` ${letter.toLowerCase()}`);
|
|
||||||
|
|
||||||
const patchOpenAPI = (document: OpenAPIObject) => {
|
|
||||||
document.paths = sortKeys(document.paths);
|
|
||||||
|
|
||||||
if (document.components?.schemas) {
|
|
||||||
const schemas = document.components.schemas as Record<string, SchemaObject>;
|
|
||||||
|
|
||||||
document.components.schemas = sortKeys(schemas);
|
|
||||||
|
|
||||||
for (const schema of Object.values(schemas)) {
|
|
||||||
if (schema.properties) {
|
|
||||||
schema.properties = sortKeys(schema.properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schema.required) {
|
|
||||||
schema.required = schema.required.sort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(document.paths)) {
|
|
||||||
const newKey = key.replace('/api/', '/');
|
|
||||||
delete document.paths[key];
|
|
||||||
document.paths[newKey] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const path of Object.values(document.paths)) {
|
|
||||||
const operations = {
|
|
||||||
get: path.get,
|
|
||||||
put: path.put,
|
|
||||||
post: path.post,
|
|
||||||
delete: path.delete,
|
|
||||||
options: path.options,
|
|
||||||
head: path.head,
|
|
||||||
patch: path.patch,
|
|
||||||
trace: path.trace,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const operation of Object.values(operations)) {
|
|
||||||
if (!operation) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((operation.security || []).some((item) => !!item[Metadata.PUBLIC_SECURITY])) {
|
|
||||||
delete operation.security;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation.summary === '') {
|
|
||||||
delete operation.summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation.operationId) {
|
|
||||||
// console.log(`${routeToErrorMessage(operation.operationId).padEnd(40)} (${operation.operationId})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation.description === '') {
|
|
||||||
delete operation.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation.parameters) {
|
|
||||||
operation.parameters = _.orderBy(operation.parameters, 'name');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return document;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
|
|
||||||
const config = new DocumentBuilder()
|
|
||||||
.setTitle('Immich')
|
|
||||||
.setDescription('Immich API')
|
|
||||||
.setVersion(serverVersion.toString())
|
|
||||||
.addBearerAuth({
|
|
||||||
type: 'http',
|
|
||||||
scheme: 'Bearer',
|
|
||||||
in: 'header',
|
|
||||||
})
|
|
||||||
.addCookieAuth(IMMICH_ACCESS_COOKIE)
|
|
||||||
.addApiKey(
|
|
||||||
{
|
|
||||||
type: 'apiKey',
|
|
||||||
in: 'header',
|
|
||||||
name: IMMICH_API_KEY_HEADER,
|
|
||||||
},
|
|
||||||
IMMICH_API_KEY_NAME,
|
|
||||||
)
|
|
||||||
.addServer('/api')
|
|
||||||
.build();
|
|
||||||
|
|
||||||
const options: SwaggerDocumentOptions = {
|
|
||||||
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
const specification = SwaggerModule.createDocument(app, config, options);
|
|
||||||
|
|
||||||
const customOptions: SwaggerCustomOptions = {
|
|
||||||
swaggerOptions: {
|
|
||||||
persistAuthorization: true,
|
|
||||||
},
|
|
||||||
customSiteTitle: 'Immich API Documentation',
|
|
||||||
};
|
|
||||||
|
|
||||||
SwaggerModule.setup('doc', app, specification, customOptions);
|
|
||||||
|
|
||||||
if (isDevelopment) {
|
|
||||||
// Generate API Documentation only in development mode
|
|
||||||
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
|
|
||||||
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });
|
|
||||||
}
|
|
||||||
};
|
|
25
server/src/interfaces/asset-v1.interface.ts
Normal file
25
server/src/interfaces/asset-v1.interface.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { CuratedLocationsResponseDto, CuratedObjectsResponseDto } from 'src/dtos/asset-v1-response.dto';
|
||||||
|
import { AssetSearchDto, CheckExistingAssetsDto, SearchPropertiesDto } from 'src/dtos/asset-v1.dto';
|
||||||
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
|
||||||
|
export interface AssetCheck {
|
||||||
|
id: string;
|
||||||
|
checksum: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetOwnerCheck extends AssetCheck {
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAssetRepositoryV1 {
|
||||||
|
get(id: string): Promise<AssetEntity | null>;
|
||||||
|
getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
|
||||||
|
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
|
||||||
|
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
|
||||||
|
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
|
||||||
|
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
|
||||||
|
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
|
||||||
|
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IAssetRepositoryV1 = 'IAssetRepositoryV1';
|
@ -7,9 +7,8 @@ import {
|
|||||||
NestInterceptor,
|
NestInterceptor,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Observable, catchError, throwError } from 'rxjs';
|
import { Observable, catchError, throwError } from 'rxjs';
|
||||||
import { routeToErrorMessage } from 'src/immich/app.utils';
|
|
||||||
import { ImmichLogger } from 'src/utils/logger';
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
import { isConnectionAborted } from 'src/utils/misc';
|
import { isConnectionAborted, routeToErrorMessage } from 'src/utils/misc';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ErrorInterceptor implements NestInterceptor {
|
export class ErrorInterceptor implements NestInterceptor {
|
||||||
|
@ -1,43 +1,16 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { CuratedLocationsResponseDto, CuratedObjectsResponseDto } from 'src/dtos/asset-v1-response.dto';
|
||||||
|
import { AssetSearchDto, CheckExistingAssetsDto, SearchPropertiesDto } from 'src/dtos/asset-v1.dto';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { AssetCheck, AssetOwnerCheck, IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||||
import { AssetSearchDto } from 'src/immich/api-v1/asset/dto/asset-search.dto';
|
|
||||||
import { CheckExistingAssetsDto } from 'src/immich/api-v1/asset/dto/check-existing-assets.dto';
|
|
||||||
import { SearchPropertiesDto } from 'src/immich/api-v1/asset/dto/search-properties.dto';
|
|
||||||
import { CuratedLocationsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-locations-response.dto';
|
|
||||||
import { CuratedObjectsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-objects-response.dto';
|
|
||||||
import { OptionalBetween } from 'src/utils/database';
|
import { OptionalBetween } from 'src/utils/database';
|
||||||
import { In } from 'typeorm/find-options/operator/In.js';
|
import { In } from 'typeorm/find-options/operator/In.js';
|
||||||
import { Repository } from 'typeorm/repository/Repository.js';
|
import { Repository } from 'typeorm/repository/Repository.js';
|
||||||
export interface AssetCheck {
|
|
||||||
id: string;
|
|
||||||
checksum: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AssetOwnerCheck extends AssetCheck {
|
|
||||||
ownerId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IAssetRepositoryV1 {
|
|
||||||
get(id: string): Promise<AssetEntity | null>;
|
|
||||||
getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
|
|
||||||
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
|
|
||||||
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
|
|
||||||
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
|
|
||||||
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
|
|
||||||
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
|
|
||||||
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IAssetRepositoryV1 = 'IAssetRepositoryV1';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetRepositoryV1 implements IAssetRepositoryV1 {
|
export class AssetRepositoryV1 implements IAssetRepositoryV1 {
|
||||||
constructor(
|
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
|
||||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
|
||||||
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves all assets by user ID.
|
* Retrieves all assets by user ID.
|
@ -1,15 +1,15 @@
|
|||||||
import { when } from 'jest-when';
|
import { when } from 'jest-when';
|
||||||
|
import { AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-v1-response.dto';
|
||||||
|
import { CreateAssetDto } from 'src/dtos/asset-v1.dto';
|
||||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
import { ExifEntity } from 'src/entities/exif.entity';
|
||||||
import { IAssetRepositoryV1 } from 'src/immich/api-v1/asset/asset-repository';
|
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||||
import { AssetService } from 'src/immich/api-v1/asset/asset.service';
|
|
||||||
import { CreateAssetDto } from 'src/immich/api-v1/asset/dto/create-asset.dto';
|
|
||||||
import { AssetRejectReason, AssetUploadAction } from 'src/immich/api-v1/asset/response-dto/asset-check-response.dto';
|
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||||
|
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { fileStub } from 'test/fixtures/file.stub';
|
import { fileStub } from 'test/fixtures/file.stub';
|
||||||
@ -60,7 +60,7 @@ const _getAsset_1 = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('AssetService', () => {
|
describe('AssetService', () => {
|
||||||
let sut: AssetService;
|
let sut: AssetServiceV1;
|
||||||
let accessMock: IAccessRepositoryMock;
|
let accessMock: IAccessRepositoryMock;
|
||||||
let assetRepositoryMockV1: jest.Mocked<IAssetRepositoryV1>;
|
let assetRepositoryMockV1: jest.Mocked<IAssetRepositoryV1>;
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
@ -88,7 +88,7 @@ describe('AssetService', () => {
|
|||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
|
|
||||||
sut = new AssetService(accessMock, assetRepositoryMockV1, assetMock, jobMock, libraryMock, storageMock, userMock);
|
sut = new AssetServiceV1(accessMock, assetRepositoryMockV1, assetMock, jobMock, libraryMock, storageMock, userMock);
|
||||||
|
|
||||||
when(assetRepositoryMockV1.get)
|
when(assetRepositoryMockV1.get)
|
||||||
.calledWith(assetStub.livePhotoStillAsset.id)
|
.calledWith(assetStub.livePhotoStillAsset.id)
|
@ -7,26 +7,29 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
|
import {
|
||||||
|
AssetBulkUploadCheckResponseDto,
|
||||||
|
AssetFileUploadResponseDto,
|
||||||
|
AssetRejectReason,
|
||||||
|
AssetUploadAction,
|
||||||
|
CheckExistingAssetsResponseDto,
|
||||||
|
CuratedLocationsResponseDto,
|
||||||
|
CuratedObjectsResponseDto,
|
||||||
|
} from 'src/dtos/asset-v1-response.dto';
|
||||||
|
import {
|
||||||
|
AssetBulkUploadCheckDto,
|
||||||
|
AssetSearchDto,
|
||||||
|
CheckExistingAssetsDto,
|
||||||
|
CreateAssetDto,
|
||||||
|
GetAssetThumbnailDto,
|
||||||
|
GetAssetThumbnailFormatEnum,
|
||||||
|
ServeFileDto,
|
||||||
|
} from 'src/dtos/asset-v1.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||||
import { LibraryType } from 'src/entities/library.entity';
|
import { LibraryType } from 'src/entities/library.entity';
|
||||||
import { IAssetRepositoryV1 } from 'src/immich/api-v1/asset/asset-repository';
|
|
||||||
import { AssetBulkUploadCheckDto } from 'src/immich/api-v1/asset/dto/asset-check.dto';
|
|
||||||
import { AssetSearchDto } from 'src/immich/api-v1/asset/dto/asset-search.dto';
|
|
||||||
import { CheckExistingAssetsDto } from 'src/immich/api-v1/asset/dto/check-existing-assets.dto';
|
|
||||||
import { CreateAssetDto } from 'src/immich/api-v1/asset/dto/create-asset.dto';
|
|
||||||
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from 'src/immich/api-v1/asset/dto/get-asset-thumbnail.dto';
|
|
||||||
import { ServeFileDto } from 'src/immich/api-v1/asset/dto/serve-file.dto';
|
|
||||||
import {
|
|
||||||
AssetBulkUploadCheckResponseDto,
|
|
||||||
AssetRejectReason,
|
|
||||||
AssetUploadAction,
|
|
||||||
} from 'src/immich/api-v1/asset/response-dto/asset-check-response.dto';
|
|
||||||
import { AssetFileUploadResponseDto } from 'src/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
|
|
||||||
import { CheckExistingAssetsResponseDto } from 'src/immich/api-v1/asset/response-dto/check-existing-assets-response.dto';
|
|
||||||
import { CuratedLocationsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-locations-response.dto';
|
|
||||||
import { CuratedObjectsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-objects-response.dto';
|
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
|
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||||
@ -39,8 +42,9 @@ import { mimeTypes } from 'src/utils/mime-types';
|
|||||||
import { QueryFailedError } from 'typeorm';
|
import { QueryFailedError } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetService {
|
/** @deprecated */
|
||||||
readonly logger = new ImmichLogger(AssetService.name);
|
export class AssetServiceV1 {
|
||||||
|
readonly logger = new ImmichLogger(AssetServiceV1.name);
|
||||||
private access: AccessCore;
|
private access: AccessCore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
@ -1,4 +1,11 @@
|
|||||||
import { basename, extname } from 'node:path';
|
import { HttpException, StreamableFile } from '@nestjs/common';
|
||||||
|
import { NextFunction, Response } from 'express';
|
||||||
|
import { access, constants } from 'node:fs/promises';
|
||||||
|
import { basename, extname, isAbsolute } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { ImmichReadStream } from 'src/interfaces/storage.interface';
|
||||||
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
|
import { isConnectionAborted } from 'src/utils/misc';
|
||||||
|
|
||||||
export function getFileNameWithoutExtension(path: string): string {
|
export function getFileNameWithoutExtension(path: string): string {
|
||||||
return basename(path, extname(path));
|
return basename(path, extname(path));
|
||||||
@ -23,3 +30,59 @@ export class ImmichFileResponse {
|
|||||||
Object.assign(this, response);
|
Object.assign(this, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
type SendFile = Parameters<Response['sendFile']>;
|
||||||
|
type SendFileOptions = SendFile[1];
|
||||||
|
|
||||||
|
const logger = new ImmichLogger('SendFile');
|
||||||
|
|
||||||
|
export const sendFile = async (
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
handler: () => Promise<ImmichFileResponse>,
|
||||||
|
): Promise<void> => {
|
||||||
|
const _sendFile = (path: string, options: SendFileOptions) =>
|
||||||
|
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await handler();
|
||||||
|
switch (file.cacheControl) {
|
||||||
|
case CacheControl.PRIVATE_WITH_CACHE: {
|
||||||
|
res.set('Cache-Control', 'private, max-age=86400, no-transform');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CacheControl.PRIVATE_WITHOUT_CACHE: {
|
||||||
|
res.set('Cache-Control', 'private, no-cache, no-transform');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.header('Content-Type', file.contentType);
|
||||||
|
|
||||||
|
const options: SendFileOptions = { dotfiles: 'allow' };
|
||||||
|
if (!isAbsolute(file.path)) {
|
||||||
|
options.root = process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
await access(file.path, constants.R_OK);
|
||||||
|
|
||||||
|
return _sendFile(file.path, options);
|
||||||
|
} catch (error: Error | any) {
|
||||||
|
// ignore client-closed connection
|
||||||
|
if (isConnectionAborted(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// log non-http errors
|
||||||
|
if (error instanceof HttpException === false) {
|
||||||
|
logger.error(`Unable to send file: ${error.name}`, error.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.header('Cache-Control', 'none');
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
||||||
|
return new StreamableFile(stream, { type, length });
|
||||||
|
};
|
||||||
|
@ -13,8 +13,7 @@ import { snakeCase, startCase } from 'lodash';
|
|||||||
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
||||||
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
|
import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
|
||||||
import { performance } from 'node:perf_hooks';
|
import { performance } from 'node:perf_hooks';
|
||||||
import { excludePaths } from 'src/config';
|
import { excludePaths, serverVersion } from 'src/constants';
|
||||||
import { serverVersion } from 'src/constants';
|
|
||||||
import { DecorateAll } from 'src/decorators';
|
import { DecorateAll } from 'src/decorators';
|
||||||
|
|
||||||
let metricsEnabled = process.env.IMMICH_METRICS === 'true';
|
let metricsEnabled = process.env.IMMICH_METRICS === 'true';
|
||||||
|
@ -1,4 +1,23 @@
|
|||||||
import { CLIP_MODEL_INFO } from 'src/constants';
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
DocumentBuilder,
|
||||||
|
OpenAPIObject,
|
||||||
|
SwaggerCustomOptions,
|
||||||
|
SwaggerDocumentOptions,
|
||||||
|
SwaggerModule,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
CLIP_MODEL_INFO,
|
||||||
|
IMMICH_ACCESS_COOKIE,
|
||||||
|
IMMICH_API_KEY_HEADER,
|
||||||
|
IMMICH_API_KEY_NAME,
|
||||||
|
serverVersion,
|
||||||
|
} from 'src/constants';
|
||||||
|
import { Metadata } from 'src/middleware/auth.guard';
|
||||||
import { ImmichLogger } from 'src/utils/logger';
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
|
|
||||||
export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED';
|
export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED';
|
||||||
@ -30,3 +49,130 @@ export function getCLIPModelInfo(modelName: string) {
|
|||||||
|
|
||||||
return modelInfo;
|
return modelInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortKeys<T>(target: T): T {
|
||||||
|
if (!target || typeof target !== 'object' || Array.isArray(target)) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Partial<T> = {};
|
||||||
|
const keys = Object.keys(target).sort() as Array<keyof T>;
|
||||||
|
for (const key of keys) {
|
||||||
|
result[key] = sortKeys(target[key]);
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routeToErrorMessage = (methodName: string) =>
|
||||||
|
'Failed to ' + methodName.replaceAll(/[A-Z]+/g, (letter) => ` ${letter.toLowerCase()}`);
|
||||||
|
|
||||||
|
const patchOpenAPI = (document: OpenAPIObject) => {
|
||||||
|
document.paths = sortKeys(document.paths);
|
||||||
|
|
||||||
|
if (document.components?.schemas) {
|
||||||
|
const schemas = document.components.schemas as Record<string, SchemaObject>;
|
||||||
|
|
||||||
|
document.components.schemas = sortKeys(schemas);
|
||||||
|
|
||||||
|
for (const schema of Object.values(schemas)) {
|
||||||
|
if (schema.properties) {
|
||||||
|
schema.properties = sortKeys(schema.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.required) {
|
||||||
|
schema.required = schema.required.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(document.paths)) {
|
||||||
|
const newKey = key.replace('/api/', '/');
|
||||||
|
delete document.paths[key];
|
||||||
|
document.paths[newKey] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of Object.values(document.paths)) {
|
||||||
|
const operations = {
|
||||||
|
get: path.get,
|
||||||
|
put: path.put,
|
||||||
|
post: path.post,
|
||||||
|
delete: path.delete,
|
||||||
|
options: path.options,
|
||||||
|
head: path.head,
|
||||||
|
patch: path.patch,
|
||||||
|
trace: path.trace,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const operation of Object.values(operations)) {
|
||||||
|
if (!operation) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((operation.security || []).some((item) => !!item[Metadata.PUBLIC_SECURITY])) {
|
||||||
|
delete operation.security;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.summary === '') {
|
||||||
|
delete operation.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.operationId) {
|
||||||
|
// console.log(`${routeToErrorMessage(operation.operationId).padEnd(40)} (${operation.operationId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.description === '') {
|
||||||
|
delete operation.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.parameters) {
|
||||||
|
operation.parameters = _.orderBy(operation.parameters, 'name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Immich')
|
||||||
|
.setDescription('Immich API')
|
||||||
|
.setVersion(serverVersion.toString())
|
||||||
|
.addBearerAuth({
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'Bearer',
|
||||||
|
in: 'header',
|
||||||
|
})
|
||||||
|
.addCookieAuth(IMMICH_ACCESS_COOKIE)
|
||||||
|
.addApiKey(
|
||||||
|
{
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: IMMICH_API_KEY_HEADER,
|
||||||
|
},
|
||||||
|
IMMICH_API_KEY_NAME,
|
||||||
|
)
|
||||||
|
.addServer('/api')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const options: SwaggerDocumentOptions = {
|
||||||
|
operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
const specification = SwaggerModule.createDocument(app, config, options);
|
||||||
|
|
||||||
|
const customOptions: SwaggerCustomOptions = {
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
},
|
||||||
|
customSiteTitle: 'Immich API Documentation',
|
||||||
|
};
|
||||||
|
|
||||||
|
SwaggerModule.setup('doc', app, specification, customOptions);
|
||||||
|
|
||||||
|
if (isDevelopment) {
|
||||||
|
// Generate API Documentation only in development mode
|
||||||
|
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
|
||||||
|
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user