mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
add note about RFC 9651
authdto remove excess logs use structured dictionary
This commit is contained in:
parent
0105c9e2b6
commit
597382a25f
4 changed files with 99 additions and 92 deletions
|
|
@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|||
import { createUserDto } from 'src/fixtures';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
|
||||
import { serializeDictionary } from 'structured-headers';
|
||||
import request from 'supertest';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ describe('/upload', () => {
|
|||
let quotaUser: LoginResponseDto;
|
||||
let cancelQuotaUser: LoginResponseDto;
|
||||
|
||||
let base64Metadata: string;
|
||||
let assetData: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
|
@ -20,16 +21,15 @@ describe('/upload', () => {
|
|||
user = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||
cancelQuotaUser = await utils.userSetup(admin.accessToken, createUserDto.user2);
|
||||
quotaUser = await utils.userSetup(admin.accessToken, createUserDto.userQuota);
|
||||
base64Metadata = Buffer.from(
|
||||
JSON.stringify({
|
||||
filename: 'test-image.jpg',
|
||||
deviceAssetId: 'rufh',
|
||||
deviceId: 'test',
|
||||
fileCreatedAt: new Date('2025-01-02T00:00:00Z').toISOString(),
|
||||
fileModifiedAt: new Date('2025-01-01T00:00:00Z').toISOString(),
|
||||
isFavorite: false,
|
||||
}),
|
||||
).toString('base64');
|
||||
assetData = serializeDictionary({
|
||||
filename: 'test-image.jpg',
|
||||
'device-asset-id': 'rufh',
|
||||
'device-id': 'test',
|
||||
'file-created-at': new Date('2025-01-02T00:00:00Z').toISOString(),
|
||||
'file-modified-at': new Date('2025-01-01T00:00:00Z').toISOString(),
|
||||
'is-favorite': false,
|
||||
'icloud-id': 'example-icloud-id',
|
||||
});
|
||||
});
|
||||
|
||||
describe('startUpload', () => {
|
||||
|
|
@ -39,7 +39,7 @@ describe('/upload', () => {
|
|||
const { status, headers } = await request(app)
|
||||
.post('/upload')
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -57,7 +57,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -75,7 +75,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Incomplete', '?0')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -93,7 +93,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Upload-Length', '2000')
|
||||
|
|
@ -115,7 +115,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -137,7 +137,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Content-Length', '512')
|
||||
|
|
@ -156,7 +156,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '3')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`)
|
||||
.set('Upload-Incomplete', '?1')
|
||||
.set('Content-Length', '512')
|
||||
|
|
@ -175,7 +175,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:INVALID:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -194,7 +194,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -208,7 +208,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -231,7 +231,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -246,7 +246,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -264,7 +264,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update('').digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -283,7 +283,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?1')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -325,7 +325,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(fullContent).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Upload-Length', '2750')
|
||||
|
|
@ -358,7 +358,7 @@ describe('/upload', () => {
|
|||
.send(chunks[2]);
|
||||
|
||||
expect(status).toBe(404);
|
||||
expect(headers['upload-complete']).toBeUndefined();
|
||||
expect(headers['upload-complete']).toEqual('?0');
|
||||
});
|
||||
|
||||
it('should append data with correct offset', async () => {
|
||||
|
|
@ -502,7 +502,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(totalContent).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Upload-Length', '5000')
|
||||
|
|
@ -539,7 +539,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${hash.digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Upload-Length', '10000')
|
||||
|
|
@ -591,7 +591,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Upload-Length', '200')
|
||||
|
|
@ -631,7 +631,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Content-Type', 'image/jpeg')
|
||||
|
|
@ -687,7 +687,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Upload-Length', '200')
|
||||
|
|
@ -723,7 +723,7 @@ describe('/upload', () => {
|
|||
.post('/upload')
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.set('Upload-Draft-Interop-Version', '8')
|
||||
.set('X-Immich-Asset-Data', base64Metadata)
|
||||
.set('X-Immich-Asset-Data', assetData)
|
||||
.set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
|
||||
.set('Upload-Complete', '?0')
|
||||
.set('Upload-Length', '512')
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ import {
|
|||
import { ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto, UploadHeader } from 'src/dtos/upload.dto';
|
||||
import { ImmichHeader, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AssetUploadService } from 'src/services/asset-upload.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
|
|
@ -53,23 +53,31 @@ export class AssetUploadController {
|
|||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.AssetData,
|
||||
description:
|
||||
'Base64-encoded JSON of asset metadata. The expected content is the same as AssetMediaCreateDto, except that `filename` is required and `sidecarData` is ignored.',
|
||||
description: `RFC 9651 structured dictionary containing asset metadata with the following keys:
|
||||
- device-asset-id (string, required): Unique device asset identifier
|
||||
- device-id (string, required): Device identifier
|
||||
- file-created-at (string/date, required): ISO 8601 date string or Unix timestamp
|
||||
- file-modified-at (string/date, required): ISO 8601 date string or Unix timestamp
|
||||
- filename (string, required): Original filename
|
||||
- duration (string, optional): Duration for video assets
|
||||
- is-favorite (boolean, optional): Favorite status
|
||||
- icloud-id (string, optional): iCloud identifier for assets from iOS devices`,
|
||||
required: true,
|
||||
example:
|
||||
'device-asset-id="abc123", device-id="phone1", filename="photo.jpg", file-created-at="2024-01-01T00:00:00Z", file-modified-at="2024-01-01T00:00:00Z"',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: UploadHeader.ReprDigest,
|
||||
description:
|
||||
'Structured dictionary containing an SHA-1 checksum used to detect duplicate files and validate data integrity.',
|
||||
'RFC 9651 structured dictionary containing an `sha` (bytesequence) checksum used to detect duplicate files and validate data integrity.',
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(apiInteropVersion)
|
||||
@ApiHeader(apiUploadComplete)
|
||||
@ApiHeader(apiContentLength)
|
||||
startUpload(@Req() req: AuthenticatedRequest, @Res() res: Response): Promise<void> {
|
||||
startUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const dto = this.getDto(StartUploadDto, req.headers);
|
||||
console.log('Starting upload with dto:', JSON.stringify(dto));
|
||||
return this.service.startUpload(req, res, dto);
|
||||
return this.service.startUpload(auth, req, res, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
|
|
@ -83,10 +91,9 @@ export class AssetUploadController {
|
|||
@ApiHeader(apiInteropVersion)
|
||||
@ApiHeader(apiUploadComplete)
|
||||
@ApiHeader(apiContentLength)
|
||||
resumeUpload(@Req() req: AuthenticatedRequest, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
resumeUpload(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
const dto = this.getDto(ResumeUploadDto, req.headers);
|
||||
console.log('Resuming upload with dto:', JSON.stringify(dto));
|
||||
return this.service.resumeUpload(req, res, id, dto);
|
||||
return this.service.resumeUpload(auth, req, res, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
|
@ -98,10 +105,9 @@ export class AssetUploadController {
|
|||
@Head(':id')
|
||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||
@ApiHeader(apiInteropVersion)
|
||||
getUploadStatus(@Req() req: AuthenticatedRequest, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
getUploadStatus(@Auth() auth: AuthDto, @Req() req: Request, @Res() res: Response, @Param() { id }: UUIDParamDto) {
|
||||
const dto = this.getDto(GetUploadStatusDto, req.headers);
|
||||
console.log('Getting upload status with dto:', JSON.stringify(dto));
|
||||
return this.service.getUploadStatus(req.auth, res, id, dto);
|
||||
return this.service.getUploadStatus(auth, res, id, dto);
|
||||
}
|
||||
|
||||
@Options()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Expose, plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { Equals, IsArray, IsEnum, IsInt, IsNotEmpty, IsString, Min, ValidateIf, ValidateNested } from 'class-validator';
|
||||
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
|
||||
import { Equals, IsEnum, IsInt, IsNotEmpty, IsString, Min, ValidateIf, ValidateNested } from 'class-validator';
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
|
||||
import { parseDictionary } from 'structured-headers';
|
||||
|
|
@ -32,19 +31,10 @@ export class UploadAssetDataDto {
|
|||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@Transform(({ value }) => {
|
||||
try {
|
||||
const json = JSON.parse(value);
|
||||
const items = Array.isArray(json) ? json : [json];
|
||||
return items.map((item) => plainToInstance(AssetMetadataUpsertItemDto, item));
|
||||
} catch {
|
||||
throw new BadRequestException(['metadata must be valid JSON']);
|
||||
}
|
||||
})
|
||||
@Optional()
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
metadata!: AssetMetadataUpsertItemDto[];
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
iCloudId!: string;
|
||||
}
|
||||
|
||||
export enum StructuredBoolean {
|
||||
|
|
@ -78,12 +68,12 @@ export class BaseUploadHeadersDto extends BaseRufhHeadersDto {
|
|||
contentLength!: number;
|
||||
|
||||
@Expose({ name: UploadHeader.UploadComplete })
|
||||
@ValidateIf((o) => o.version === null || o.version! > 3)
|
||||
@ValidateIf((o) => o.version === null || o.version > 3)
|
||||
@IsEnum(StructuredBoolean)
|
||||
uploadComplete!: StructuredBoolean;
|
||||
|
||||
@Expose({ name: UploadHeader.UploadIncomplete })
|
||||
@ValidateIf((o) => o.version !== null && o.version! <= 3)
|
||||
@ValidateIf((o) => o.version !== null && o.version <= 3)
|
||||
@IsEnum(StructuredBoolean)
|
||||
uploadIncomplete!: StructuredBoolean;
|
||||
|
||||
|
|
@ -97,19 +87,26 @@ export class BaseUploadHeadersDto extends BaseRufhHeadersDto {
|
|||
|
||||
export class StartUploadDto extends BaseUploadHeadersDto {
|
||||
@Expose({ name: ImmichHeader.AssetData })
|
||||
// @ValidateNested()
|
||||
// @IsObject()
|
||||
@Type(() => UploadAssetDataDto)
|
||||
@ValidateNested()
|
||||
@Transform(({ value }) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
throw new BadRequestException(`${ImmichHeader.AssetData} header is required`);
|
||||
}
|
||||
|
||||
const json = Buffer.from(value, 'base64').toString('utf-8');
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
throw new BadRequestException(`${ImmichHeader.AssetData} must be valid base64-encoded JSON`);
|
||||
const dict = parseDictionary(value);
|
||||
return plainToInstance(UploadAssetDataDto, {
|
||||
deviceAssetId: dict.get('device-asset-id')?.[0],
|
||||
deviceId: dict.get('device-id')?.[0],
|
||||
filename: dict.get('filename')?.[0],
|
||||
duration: dict.get('duration')?.[0],
|
||||
fileCreatedAt: dict.get('file-created-at')?.[0],
|
||||
fileModifiedAt: dict.get('file-modified-at')?.[0],
|
||||
isFavorite: dict.get('is-favorite')?.[0],
|
||||
iCloudId: dict.get('icloud-id')?.[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
throw new BadRequestException(`${ImmichHeader.AssetData} must be a valid structured dictionary`);
|
||||
}
|
||||
})
|
||||
assetData!: UploadAssetDataDto;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,16 @@ import { StorageCore } from 'src/cores/storage.core';
|
|||
import { OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto } from 'src/dtos/upload.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
|
||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import {
|
||||
AssetMetadataKey,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
|
|
@ -21,12 +29,12 @@ export const MAX_RUFH_INTEROP_VERSION = 8;
|
|||
|
||||
@Injectable()
|
||||
export class AssetUploadService extends BaseService {
|
||||
async startUpload(req: AuthenticatedRequest, res: Response, dto: StartUploadDto): Promise<void> {
|
||||
async startUpload(auth: AuthDto, req: Readable, res: Response, dto: StartUploadDto): Promise<void> {
|
||||
this.logger.verboseFn(() => `Starting upload: ${JSON.stringify(dto)}`);
|
||||
const { isComplete, assetData, uploadLength, contentLength, version } = dto;
|
||||
|
||||
const assetId = this.cryptoRepository.randomUUID();
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, req.auth.user.id, assetId);
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
|
||||
const extension = extname(assetData.filename);
|
||||
const path = join(folder, `${assetId}${extension}`);
|
||||
const type = mimeTypes.assetType(path);
|
||||
|
|
@ -35,13 +43,13 @@ export class AssetUploadService extends BaseService {
|
|||
throw new BadRequestException(`${assetData.filename} is an unsupported file type`);
|
||||
}
|
||||
|
||||
this.validateQuota(req.auth, uploadLength ?? contentLength);
|
||||
this.validateQuota(auth, uploadLength ?? contentLength);
|
||||
|
||||
try {
|
||||
await this.assetRepository.createWithMetadata(
|
||||
{
|
||||
id: assetId,
|
||||
ownerId: req.auth.user.id,
|
||||
ownerId: auth.user.id,
|
||||
libraryId: null,
|
||||
checksum: dto.checksum,
|
||||
originalPath: path,
|
||||
|
|
@ -58,7 +66,7 @@ export class AssetUploadService extends BaseService {
|
|||
status: AssetStatus.Partial,
|
||||
},
|
||||
uploadLength,
|
||||
assetData.metadata,
|
||||
assetData.iCloudId ? [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: assetData.iCloudId } }] : undefined,
|
||||
);
|
||||
} catch (error: any) {
|
||||
if (!isAssetChecksumConstraint(error)) {
|
||||
|
|
@ -67,7 +75,7 @@ export class AssetUploadService extends BaseService {
|
|||
return;
|
||||
}
|
||||
|
||||
const duplicate = await this.assetRepository.getUploadAssetIdByChecksum(req.auth.user.id, dto.checksum);
|
||||
const duplicate = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, dto.checksum);
|
||||
if (!duplicate) {
|
||||
res.status(500).send('Error locating duplicate for checksum constraint');
|
||||
return;
|
||||
|
|
@ -126,12 +134,12 @@ export class AssetUploadService extends BaseService {
|
|||
await new Promise((resolve) => writeStream.on('close', resolve));
|
||||
}
|
||||
|
||||
resumeUpload(req: AuthenticatedRequest, res: Response, id: string, dto: ResumeUploadDto): Promise<void> {
|
||||
resumeUpload(auth: AuthDto, req: Readable, res: Response, id: string, dto: ResumeUploadDto): Promise<void> {
|
||||
this.logger.verboseFn(() => `Resuming upload for ${id}: ${JSON.stringify(dto)}`);
|
||||
const { isComplete, uploadLength, uploadOffset, contentLength, version } = dto;
|
||||
|
||||
this.setCompleteHeader(res, version, false);
|
||||
return this.databaseRepository.withUuidLock(id, async () => {
|
||||
const completionData = await this.assetRepository.getCompletionMetadata(id, req.auth.user.id);
|
||||
const completionData = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
|
||||
if (!completionData) {
|
||||
res.status(404).send('Asset not found');
|
||||
return;
|
||||
|
|
@ -139,30 +147,25 @@ export class AssetUploadService extends BaseService {
|
|||
const { fileModifiedAt, path, status, checksum: providedChecksum, size } = completionData;
|
||||
|
||||
if (status !== AssetStatus.Partial) {
|
||||
this.setCompleteHeader(res, version, false);
|
||||
return this.sendAlreadyCompletedProblem(res);
|
||||
}
|
||||
|
||||
if (uploadLength && size && size !== uploadLength) {
|
||||
this.setCompleteHeader(res, version, false);
|
||||
return this.sendInconsistentLengthProblem(res);
|
||||
}
|
||||
|
||||
const expectedOffset = await this.getCurrentOffset(path);
|
||||
if (expectedOffset !== uploadOffset) {
|
||||
this.setCompleteHeader(res, version, false);
|
||||
return this.sendOffsetMismatchProblem(res, expectedOffset, uploadOffset);
|
||||
}
|
||||
|
||||
const newLength = uploadOffset + contentLength;
|
||||
if (uploadLength !== undefined && newLength > uploadLength) {
|
||||
this.setCompleteHeader(res, version, false);
|
||||
res.status(400).send('Upload would exceed declared length');
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentLength === 0 && !isComplete) {
|
||||
this.setCompleteHeader(res, version, false);
|
||||
res.status(204).setHeader('Upload-Offset', expectedOffset.toString()).send();
|
||||
return;
|
||||
}
|
||||
|
|
@ -192,22 +195,23 @@ export class AssetUploadService extends BaseService {
|
|||
});
|
||||
}
|
||||
|
||||
cancelUpload(auth: AuthDto, assetId: string, response: Response): Promise<void> {
|
||||
cancelUpload(auth: AuthDto, assetId: string, res: Response): Promise<void> {
|
||||
return this.databaseRepository.withUuidLock(assetId, async () => {
|
||||
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
|
||||
if (!asset) {
|
||||
response.status(404).send('Asset not found');
|
||||
res.status(404).send('Asset not found');
|
||||
return;
|
||||
}
|
||||
if (asset.status !== AssetStatus.Partial) {
|
||||
return this.sendAlreadyCompletedProblem(response);
|
||||
return this.sendAlreadyCompletedProblem(res);
|
||||
}
|
||||
await this.onCancel(assetId, asset.path);
|
||||
response.status(204).send();
|
||||
res.status(204).send();
|
||||
});
|
||||
}
|
||||
|
||||
async getUploadStatus(auth: AuthDto, res: Response, id: string, { version }: GetUploadStatusDto): Promise<void> {
|
||||
this.logger.verboseFn(() => `Getting upload status for ${id} with version ${version}`);
|
||||
return this.databaseRepository.withUuidLock(id, async () => {
|
||||
const asset = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
|
||||
if (!asset) {
|
||||
|
|
@ -333,7 +337,7 @@ export class AssetUploadService extends BaseService {
|
|||
socket.write(
|
||||
'HTTP/1.1 104 Upload Resumption Supported\r\n' +
|
||||
`Location: ${location}\r\n` +
|
||||
`Upload-Limit: min-size=0\r\n` +
|
||||
'Upload-Limit: min-size=0\r\n' +
|
||||
`Upload-Draft-Interop-Version: ${interopVersion}\r\n\r\n`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue