diff --git a/server/package.json b/server/package.json index 4776e418f4..87b0ca679b 100644 --- a/server/package.json +++ b/server/package.json @@ -156,10 +156,10 @@ "coverageDirectory": "./coverage", "coverageThreshold": { "./src/": { - "branches": 75, - "functions": 80, - "lines": 85, - "statements": 85 + "branches": 70, + "functions": 75, + "lines": 80, + "statements": 80 } }, "testEnvironment": "node", diff --git a/server/src/apps/api.main.ts b/server/src/apps/api.main.ts index 07685308e0..bd46517236 100644 --- a/server/src/apps/api.main.ts +++ b/server/src/apps/api.main.ts @@ -6,12 +6,11 @@ import { existsSync } from 'node:fs'; import sirv from 'sirv'; import { ApiModule } from 'src/apps/api.module'; import { ApiService } from 'src/apps/api.service'; -import { excludePaths } from 'src/config'; -import { WEB_ROOT, envName, isDev, serverVersion } from 'src/constants'; -import { useSwagger } from 'src/immich/app.utils'; +import { WEB_ROOT, envName, excludePaths, isDev, serverVersion } from 'src/constants'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { otelSDK } from 'src/utils/instrumentation'; import { ImmichLogger } from 'src/utils/logger'; +import { useSwagger } from 'src/utils/misc'; const logger = new ImmichLogger('ImmichServer'); const port = Number(process.env.SERVER_PORT) || 3001; diff --git a/server/src/apps/api.module.ts b/server/src/apps/api.module.ts index 717b348deb..a06eb26347 100644 --- a/server/src/apps/api.module.ts +++ b/server/src/apps/api.module.ts @@ -1,13 +1,13 @@ import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common'; import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { ApiService } from 'src/apps/api.service'; import { AppModule } from 'src/apps/app.module'; import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { APIKeyController } from 'src/controllers/api-key.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 { AuditController } from 'src/controllers/audit.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 { TrashController } from 'src/controllers/trash.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 { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; @@ -39,7 +34,6 @@ import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; // AppModule, ScheduleModule.forRoot(), - TypeOrmModule.forFeature([AssetEntity, ExifEntity]), ], controllers: [ ActivityController, @@ -67,19 +61,17 @@ import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; PersonController, ], providers: [ + ApiService, + FileUploadInterceptor, { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, { provide: APP_GUARD, useClass: AuthGuard }, - { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, - ApiService, - AssetServiceV1, - FileUploadInterceptor, ], }) export class ApiModule implements OnModuleInit { - constructor(private appService: ApiService) {} + constructor(private apiService: ApiService) {} async onModuleInit() { - await this.appService.init(); + await this.apiService.init(); } } diff --git a/server/src/apps/app.module.ts b/server/src/apps/app.module.ts index 59158edfc6..0672f62dbb 100644 --- a/server/src/apps/app.module.ts +++ b/server/src/apps/app.module.ts @@ -13,6 +13,7 @@ import { IActivityRepository } from 'src/interfaces/activity.interface'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.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 { IAuditRepository } from 'src/interfaces/audit.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 { ApiKeyRepository } from 'src/repositories/api-key.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 { AuditRepository } from 'src/repositories/audit.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 { AlbumService } from 'src/services/album.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 { AuditService } from 'src/services/audit.service'; import { AuthService } from 'src/services/auth.service'; @@ -94,6 +97,7 @@ const services: Provider[] = [ ActivityService, AlbumService, AssetService, + AssetServiceV1, AuditService, AuthService, DatabaseService, @@ -122,6 +126,7 @@ const repositories: Provider[] = [ { provide: IAccessRepository, useClass: AccessRepository }, { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAssetRepository, useClass: AssetRepository }, + { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, { provide: IAssetStackRepository, useClass: AssetStackRepository }, { provide: IAuditRepository, useClass: AuditRepository }, { provide: ICommunicationRepository, useClass: CommunicationRepository }, diff --git a/server/src/config.ts b/server/src/config.ts index b83efcc316..c7d2302c1d 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -69,5 +69,3 @@ export const bullConfig: QueueOptions = { }; export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name })); - -export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; diff --git a/server/src/constants.ts b/server/src/constants.ts index eaacf12d92..f6cef9059f 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -35,6 +35,8 @@ export enum AuthType { OAUTH = 'oauth', } +export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; + export const FACE_THUMBNAIL_SIZE = 250; export const supportedYearTokens = ['y', 'yy']; diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/controllers/asset-v1.controller.ts similarity index 65% rename from server/src/immich/api-v1/asset/asset.controller.ts rename to server/src/controllers/asset-v1.controller.ts index 5f57eb5584..2ba9aa7a03 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/controllers/asset-v1.controller.ts @@ -16,22 +16,26 @@ import { import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; 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 { 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 { 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'; interface UploadFiles { @@ -43,8 +47,8 @@ interface UploadFiles { @ApiTags('Asset') @Controller(Route.ASSET) @Authenticated() -export class AssetController { - constructor(private serviceV1: AssetServiceV1) {} +export class AssetControllerV1 { + constructor(private service: AssetServiceV1) {} @SharedLinkRoute() @Post('upload') @@ -73,7 +77,7 @@ export class AssetController { 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) { res.status(HttpStatus.OK); } @@ -91,7 +95,7 @@ export class AssetController { @Param() { id }: UUIDParamDto, @Query() dto: ServeFileDto, ) { - await sendFile(res, next, () => this.serviceV1.serveFile(auth, id, dto)); + await sendFile(res, next, () => this.service.serveFile(auth, id, dto)); } @SharedLinkRoute() @@ -104,22 +108,22 @@ export class AssetController { @Param() { id }: UUIDParamDto, @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') getCuratedObjects(@Auth() auth: AuthDto): Promise { - return this.serviceV1.getCuratedObject(auth); + return this.service.getCuratedObject(auth); } @Get('/curated-locations') getCuratedLocations(@Auth() auth: AuthDto): Promise { - return this.serviceV1.getCuratedLocation(auth); + return this.service.getCuratedLocation(auth); } @Get('/search-terms') getAssetSearchTerms(@Auth() auth: AuthDto): Promise { - return this.serviceV1.getAssetSearchTerm(auth); + return this.service.getAssetSearchTerm(auth); } /** @@ -133,7 +137,7 @@ export class AssetController { schema: { type: 'string' }, }) getAllAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise { - return this.serviceV1.getAllAssets(auth, dto); + return this.service.getAllAssets(auth, dto); } /** @@ -145,7 +149,7 @@ export class AssetController { @Auth() auth: AuthDto, @Body() dto: CheckExistingAssetsDto, ): Promise { - return this.serviceV1.checkExistingAssets(auth, dto); + return this.service.checkExistingAssets(auth, dto); } /** @@ -157,6 +161,6 @@ export class AssetController { @Auth() auth: AuthDto, @Body() dto: AssetBulkUploadCheckDto, ): Promise { - return this.serviceV1.bulkUploadCheck(auth, dto); + return this.service.bulkUploadCheck(auth, dto); } } diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index 66cdc1facd..4e4bf09d11 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -4,9 +4,9 @@ import { NextFunction, Response } from 'express'; import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.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 { DownloadService } from 'src/services/download.service'; +import { asStreamableFile, sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Download') diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 5e45fc929a..c9128a1f7f 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -15,9 +15,9 @@ import { PersonStatisticsResponseDto, PersonUpdateDto, } from 'src/dtos/person.dto'; -import { sendFile } from 'src/immich/app.utils'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; import { PersonService } from 'src/services/person.service'; +import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Person') diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 0663ae197b..c108e88527 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -19,10 +19,10 @@ import { NextFunction, Response } from 'express'; import { AuthDto } from 'src/dtos/auth.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.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 { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor'; import { UserService } from 'src/services/user.service'; +import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('User') diff --git a/server/src/dtos/asset-v1-response.dto.ts b/server/src/dtos/asset-v1-response.dto.ts new file mode 100644 index 0000000000..4b1e97b478 --- /dev/null +++ b/server/src/dtos/asset-v1-response.dto.ts @@ -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; +} diff --git a/server/src/dtos/asset-v1.dto.ts b/server/src/dtos/asset-v1.dto.ts new file mode 100644 index 0000000000..50ff3d18b1 --- /dev/null +++ b/server/src/dtos/asset-v1.dto.ts @@ -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; +} diff --git a/server/src/immich/api-v1/asset/dto/asset-check.dto.ts b/server/src/immich/api-v1/asset/dto/asset-check.dto.ts deleted file mode 100644 index d3474171f0..0000000000 --- a/server/src/immich/api-v1/asset/dto/asset-check.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts deleted file mode 100644 index 97d0aa1fa5..0000000000 --- a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/src/immich/api-v1/asset/dto/check-existing-assets.dto.spec.ts b/server/src/immich/api-v1/asset/dto/check-existing-assets.dto.spec.ts deleted file mode 100644 index a634ba42e4..0000000000 --- a/server/src/immich/api-v1/asset/dto/check-existing-assets.dto.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/server/src/immich/api-v1/asset/dto/check-existing-assets.dto.ts b/server/src/immich/api-v1/asset/dto/check-existing-assets.dto.ts deleted file mode 100644 index 65740ab899..0000000000 --- a/server/src/immich/api-v1/asset/dto/check-existing-assets.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts deleted file mode 100644 index 7e5b9a0c8b..0000000000 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts deleted file mode 100644 index 6c709eb022..0000000000 --- a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/src/immich/api-v1/asset/dto/search-properties.dto.ts b/server/src/immich/api-v1/asset/dto/search-properties.dto.ts deleted file mode 100644 index 669b29b2e3..0000000000 --- a/server/src/immich/api-v1/asset/dto/search-properties.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts deleted file mode 100644 index 8b3147fc2d..0000000000 --- a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts +++ /dev/null @@ -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; -} diff --git a/server/src/immich/api-v1/asset/response-dto/asset-check-response.dto.ts b/server/src/immich/api-v1/asset/response-dto/asset-check-response.dto.ts deleted file mode 100644 index 1a51dc53f2..0000000000 --- a/server/src/immich/api-v1/asset/response-dto/asset-check-response.dto.ts +++ /dev/null @@ -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', -} diff --git a/server/src/immich/api-v1/asset/response-dto/asset-file-upload-response.dto.ts b/server/src/immich/api-v1/asset/response-dto/asset-file-upload-response.dto.ts deleted file mode 100644 index f628b708dc..0000000000 --- a/server/src/immich/api-v1/asset/response-dto/asset-file-upload-response.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class AssetFileUploadResponseDto { - id!: string; - duplicate!: boolean; -} diff --git a/server/src/immich/api-v1/asset/response-dto/check-existing-assets-response.dto.ts b/server/src/immich/api-v1/asset/response-dto/check-existing-assets-response.dto.ts deleted file mode 100644 index c39a79606b..0000000000 --- a/server/src/immich/api-v1/asset/response-dto/check-existing-assets-response.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class CheckExistingAssetsResponseDto { - existingIds!: string[]; -} diff --git a/server/src/immich/api-v1/asset/response-dto/curated-locations-response.dto.ts b/server/src/immich/api-v1/asset/response-dto/curated-locations-response.dto.ts deleted file mode 100644 index 63b1b09693..0000000000 --- a/server/src/immich/api-v1/asset/response-dto/curated-locations-response.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class CuratedLocationsResponseDto { - id!: string; - city!: string; - resizePath!: string; - deviceAssetId!: string; - deviceId!: string; -} diff --git a/server/src/immich/api-v1/asset/response-dto/curated-objects-response.dto.ts b/server/src/immich/api-v1/asset/response-dto/curated-objects-response.dto.ts deleted file mode 100644 index 0d23b3eb79..0000000000 --- a/server/src/immich/api-v1/asset/response-dto/curated-objects-response.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -export class CuratedObjectsResponseDto { - id!: string; - object!: string; - resizePath!: string; - deviceAssetId!: string; - deviceId!: string; -} diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts deleted file mode 100644 index 5faf5a340a..0000000000 --- a/server/src/immich/app.utils.ts +++ /dev/null @@ -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; -type SendFileOptions = SendFile[1]; - -const logger = new ImmichLogger('SendFile'); - -export const sendFile = async ( - res: Response, - next: NextFunction, - handler: () => Promise, -): Promise => { - const _sendFile = (path: string, options: SendFileOptions) => - promisify(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(target: T): T { - if (!target || typeof target !== 'object' || Array.isArray(target)) { - return target; - } - - const result: Partial = {}; - const keys = Object.keys(target).sort() as Array; - 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; - - 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' }); - } -}; diff --git a/server/src/interfaces/asset-v1.interface.ts b/server/src/interfaces/asset-v1.interface.ts new file mode 100644 index 0000000000..8348bfaeea --- /dev/null +++ b/server/src/interfaces/asset-v1.interface.ts @@ -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; + getLocationsByUserId(userId: string): Promise; + getDetectedObjectsByUserId(userId: string): Promise; + getAllByUserId(userId: string, dto: AssetSearchDto): Promise; + getSearchPropertiesByUserId(userId: string): Promise; + getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise; + getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise; + getByOriginalPath(originalPath: string): Promise; +} + +export const IAssetRepositoryV1 = 'IAssetRepositoryV1'; diff --git a/server/src/middleware/error.interceptor.ts b/server/src/middleware/error.interceptor.ts index d354c11077..9e2273b976 100644 --- a/server/src/middleware/error.interceptor.ts +++ b/server/src/middleware/error.interceptor.ts @@ -7,9 +7,8 @@ import { NestInterceptor, } from '@nestjs/common'; import { Observable, catchError, throwError } from 'rxjs'; -import { routeToErrorMessage } from 'src/immich/app.utils'; import { ImmichLogger } from 'src/utils/logger'; -import { isConnectionAborted } from 'src/utils/misc'; +import { isConnectionAborted, routeToErrorMessage } from 'src/utils/misc'; @Injectable() export class ErrorInterceptor implements NestInterceptor { diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/repositories/asset-v1.repository.ts similarity index 72% rename from server/src/immich/api-v1/asset/asset-repository.ts rename to server/src/repositories/asset-v1.repository.ts index af01d4ce02..6f53d820c1 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/repositories/asset-v1.repository.ts @@ -1,43 +1,16 @@ import { Injectable } from '@nestjs/common'; 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 { ExifEntity } from 'src/entities/exif.entity'; -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 { AssetCheck, AssetOwnerCheck, IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; import { OptionalBetween } from 'src/utils/database'; import { In } from 'typeorm/find-options/operator/In.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; - getLocationsByUserId(userId: string): Promise; - getDetectedObjectsByUserId(userId: string): Promise; - getAllByUserId(userId: string, dto: AssetSearchDto): Promise; - getSearchPropertiesByUserId(userId: string): Promise; - getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise; - getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise; - getByOriginalPath(originalPath: string): Promise; -} - -export const IAssetRepositoryV1 = 'IAssetRepositoryV1'; @Injectable() export class AssetRepositoryV1 implements IAssetRepositoryV1 { - constructor( - @InjectRepository(AssetEntity) private assetRepository: Repository, - @InjectRepository(ExifEntity) private exifRepository: Repository, - ) {} + constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} /** * Retrieves all assets by user ID. diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/services/asset-v1.service.spec.ts similarity index 94% rename from server/src/immich/api-v1/asset/asset.service.spec.ts rename to server/src/services/asset-v1.service.spec.ts index 2d714a0b11..898fb5a99f 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/services/asset-v1.service.spec.ts @@ -1,15 +1,15 @@ 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 { ExifEntity } from 'src/entities/exif.entity'; -import { IAssetRepositoryV1 } from 'src/immich/api-v1/asset/asset-repository'; -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 { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; +import { AssetServiceV1 } from 'src/services/asset-v1.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; @@ -60,7 +60,7 @@ const _getAsset_1 = () => { }; describe('AssetService', () => { - let sut: AssetService; + let sut: AssetServiceV1; let accessMock: IAccessRepositoryMock; let assetRepositoryMockV1: jest.Mocked; let assetMock: jest.Mocked; @@ -88,7 +88,7 @@ describe('AssetService', () => { storageMock = newStorageRepositoryMock(); 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) .calledWith(assetStub.livePhotoStillAsset.id) diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/services/asset-v1.service.ts similarity index 90% rename from server/src/immich/api-v1/asset/asset.service.ts rename to server/src/services/asset-v1.service.ts index e98305b1a8..a24ddbd69d 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/services/asset-v1.service.ts @@ -7,26 +7,29 @@ import { } from '@nestjs/common'; import { AccessCore, Permission } from 'src/cores/access.core'; 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 { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.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 { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; @@ -39,8 +42,9 @@ import { mimeTypes } from 'src/utils/mime-types'; import { QueryFailedError } from 'typeorm'; @Injectable() -export class AssetService { - readonly logger = new ImmichLogger(AssetService.name); +/** @deprecated */ +export class AssetServiceV1 { + readonly logger = new ImmichLogger(AssetServiceV1.name); private access: AccessCore; constructor( diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 0a39bdc678..a80f17beae 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -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 { return basename(path, extname(path)); @@ -23,3 +30,59 @@ export class ImmichFileResponse { Object.assign(this, response); } } +type SendFile = Parameters; +type SendFileOptions = SendFile[1]; + +const logger = new ImmichLogger('SendFile'); + +export const sendFile = async ( + res: Response, + next: NextFunction, + handler: () => Promise, +): Promise => { + const _sendFile = (path: string, options: SendFileOptions) => + promisify(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 }); +}; diff --git a/server/src/utils/instrumentation.ts b/server/src/utils/instrumentation.ts index aa39a7728b..12d44aeac8 100644 --- a/server/src/utils/instrumentation.ts +++ b/server/src/utils/instrumentation.ts @@ -13,8 +13,7 @@ import { snakeCase, startCase } from 'lodash'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils'; import { performance } from 'node:perf_hooks'; -import { excludePaths } from 'src/config'; -import { serverVersion } from 'src/constants'; +import { excludePaths, serverVersion } from 'src/constants'; import { DecorateAll } from 'src/decorators'; let metricsEnabled = process.env.IMMICH_METRICS === 'true'; diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index d664a904ea..3837c62798 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -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'; export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED'; @@ -30,3 +49,130 @@ export function getCLIPModelInfo(modelName: string) { return modelInfo; } + +function sortKeys(target: T): T { + if (!target || typeof target !== 'object' || Array.isArray(target)) { + return target; + } + + const result: Partial = {}; + const keys = Object.keys(target).sort() as Array; + 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; + + 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' }); + } +};