add note about RFC 9651

authdto

remove excess logs

use structured dictionary
This commit is contained in:
mertalev
2025-10-03 01:24:38 -04:00
parent c33e65362a
commit 81a66350f6
4 changed files with 99 additions and 92 deletions

View File

@@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'node:crypto';
import { createUserDto } from 'src/fixtures'; import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, baseUrl, utils } from 'src/utils'; import { app, asBearerAuth, baseUrl, utils } from 'src/utils';
import { serializeDictionary } from 'structured-headers';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
@@ -12,7 +13,7 @@ describe('/upload', () => {
let quotaUser: LoginResponseDto; let quotaUser: LoginResponseDto;
let cancelQuotaUser: LoginResponseDto; let cancelQuotaUser: LoginResponseDto;
let base64Metadata: string; let assetData: string;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
@@ -20,16 +21,15 @@ describe('/upload', () => {
user = await utils.userSetup(admin.accessToken, createUserDto.user1); user = await utils.userSetup(admin.accessToken, createUserDto.user1);
cancelQuotaUser = await utils.userSetup(admin.accessToken, createUserDto.user2); cancelQuotaUser = await utils.userSetup(admin.accessToken, createUserDto.user2);
quotaUser = await utils.userSetup(admin.accessToken, createUserDto.userQuota); quotaUser = await utils.userSetup(admin.accessToken, createUserDto.userQuota);
base64Metadata = Buffer.from( assetData = serializeDictionary({
JSON.stringify({ filename: 'test-image.jpg',
filename: 'test-image.jpg', 'device-asset-id': 'rufh',
deviceAssetId: 'rufh', 'device-id': 'test',
deviceId: 'test', 'file-created-at': new Date('2025-01-02T00:00:00Z').toISOString(),
fileCreatedAt: new Date('2025-01-02T00:00:00Z').toISOString(), 'file-modified-at': new Date('2025-01-01T00:00:00Z').toISOString(),
fileModifiedAt: new Date('2025-01-01T00:00:00Z').toISOString(), 'is-favorite': false,
isFavorite: false, 'icloud-id': 'example-icloud-id',
}), });
).toString('base64');
}); });
describe('startUpload', () => { describe('startUpload', () => {
@@ -39,7 +39,7 @@ describe('/upload', () => {
const { status, headers } = await request(app) const { status, headers } = await request(app)
.post('/upload') .post('/upload')
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -57,7 +57,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -75,7 +75,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '3') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Incomplete', '?0') .set('Upload-Incomplete', '?0')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -93,7 +93,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Upload-Length', '2000') .set('Upload-Length', '2000')
@@ -115,7 +115,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -137,7 +137,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Content-Length', '512') .set('Content-Length', '512')
@@ -156,7 +156,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '3') .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('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`)
.set('Upload-Incomplete', '?1') .set('Upload-Incomplete', '?1')
.set('Content-Length', '512') .set('Content-Length', '512')
@@ -175,7 +175,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .set('Upload-Draft-Interop-Version', '8')
.set('X-Immich-Asset-Data', base64Metadata) .set('X-Immich-Asset-Data', assetData)
.set('Repr-Digest', `sha=:INVALID:`) .set('Repr-Digest', `sha=:INVALID:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -194,7 +194,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -208,7 +208,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -231,7 +231,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -246,7 +246,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -264,7 +264,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update('').digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -283,7 +283,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${quotaUser.accessToken}`) .set('Authorization', `Bearer ${quotaUser.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?1') .set('Upload-Complete', '?1')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -325,7 +325,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(fullContent).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '2750') .set('Upload-Length', '2750')
@@ -358,7 +358,7 @@ describe('/upload', () => {
.send(chunks[2]); .send(chunks[2]);
expect(status).toBe(404); expect(status).toBe(404);
expect(headers['upload-complete']).toBeUndefined(); expect(headers['upload-complete']).toEqual('?0');
}); });
it('should append data with correct offset', async () => { it('should append data with correct offset', async () => {
@@ -502,7 +502,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(totalContent).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '5000') .set('Upload-Length', '5000')
@@ -539,7 +539,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${hash.digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '10000') .set('Upload-Length', '10000')
@@ -591,7 +591,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '200') .set('Upload-Length', '200')
@@ -631,7 +631,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Content-Type', 'image/jpeg') .set('Content-Type', 'image/jpeg')
@@ -687,7 +687,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`) .set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '200') .set('Upload-Length', '200')
@@ -723,7 +723,7 @@ describe('/upload', () => {
.post('/upload') .post('/upload')
.set('Authorization', `Bearer ${user.accessToken}`) .set('Authorization', `Bearer ${user.accessToken}`)
.set('Upload-Draft-Interop-Version', '8') .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('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`)
.set('Upload-Complete', '?0') .set('Upload-Complete', '?0')
.set('Upload-Length', '512') .set('Upload-Length', '512')

View File

@@ -16,12 +16,12 @@ import {
import { ApiHeader, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiTags } from '@nestjs/swagger';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator'; import { validateSync } from 'class-validator';
import { Response } from 'express'; import { Request, Response } from 'express';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto, UploadHeader } from 'src/dtos/upload.dto'; import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto, UploadHeader } from 'src/dtos/upload.dto';
import { ImmichHeader, Permission } from 'src/enum'; 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 { AssetUploadService } from 'src/services/asset-upload.service';
import { UUIDParamDto } from 'src/validation'; import { UUIDParamDto } from 'src/validation';
@@ -53,23 +53,31 @@ export class AssetUploadController {
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) @Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
@ApiHeader({ @ApiHeader({
name: ImmichHeader.AssetData, name: ImmichHeader.AssetData,
description: description: `RFC 9651 structured dictionary containing asset metadata with the following keys:
'Base64-encoded JSON of asset metadata. The expected content is the same as AssetMediaCreateDto, except that `filename` is required and `sidecarData` is ignored.', - 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, 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({ @ApiHeader({
name: UploadHeader.ReprDigest, name: UploadHeader.ReprDigest,
description: 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, required: true,
}) })
@ApiHeader(apiInteropVersion) @ApiHeader(apiInteropVersion)
@ApiHeader(apiUploadComplete) @ApiHeader(apiUploadComplete)
@ApiHeader(apiContentLength) @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); const dto = this.getDto(StartUploadDto, req.headers);
console.log('Starting upload with dto:', JSON.stringify(dto)); return this.service.startUpload(auth, req, res, dto);
return this.service.startUpload(req, res, dto);
} }
@Patch(':id') @Patch(':id')
@@ -83,10 +91,9 @@ export class AssetUploadController {
@ApiHeader(apiInteropVersion) @ApiHeader(apiInteropVersion)
@ApiHeader(apiUploadComplete) @ApiHeader(apiUploadComplete)
@ApiHeader(apiContentLength) @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); const dto = this.getDto(ResumeUploadDto, req.headers);
console.log('Resuming upload with dto:', JSON.stringify(dto)); return this.service.resumeUpload(auth, req, res, id, dto);
return this.service.resumeUpload(req, res, id, dto);
} }
@Delete(':id') @Delete(':id')
@@ -98,10 +105,9 @@ export class AssetUploadController {
@Head(':id') @Head(':id')
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) @Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
@ApiHeader(apiInteropVersion) @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); const dto = this.getDto(GetUploadStatusDto, req.headers);
console.log('Getting upload status with dto:', JSON.stringify(dto)); return this.service.getUploadStatus(auth, res, id, dto);
return this.service.getUploadStatus(req.auth, res, id, dto);
} }
@Options() @Options()

View File

@@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Expose, plainToInstance, Transform, Type } from 'class-transformer'; import { Expose, plainToInstance, Transform, Type } from 'class-transformer';
import { Equals, IsArray, IsEnum, IsInt, IsNotEmpty, IsString, Min, ValidateIf, ValidateNested } from 'class-validator'; import { Equals, IsEnum, IsInt, IsNotEmpty, IsString, Min, ValidateIf, ValidateNested } from 'class-validator';
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
import { ImmichHeader } from 'src/enum'; import { ImmichHeader } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
import { parseDictionary } from 'structured-headers'; import { parseDictionary } from 'structured-headers';
@@ -32,19 +31,10 @@ export class UploadAssetDataDto {
@ValidateBoolean({ optional: true }) @ValidateBoolean({ optional: true })
isFavorite?: boolean; 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() @Optional()
@ValidateNested({ each: true }) @IsString()
@IsArray() @IsNotEmpty()
metadata!: AssetMetadataUpsertItemDto[]; iCloudId!: string;
} }
export enum StructuredBoolean { export enum StructuredBoolean {
@@ -78,12 +68,12 @@ export class BaseUploadHeadersDto extends BaseRufhHeadersDto {
contentLength!: number; contentLength!: number;
@Expose({ name: UploadHeader.UploadComplete }) @Expose({ name: UploadHeader.UploadComplete })
@ValidateIf((o) => o.version === null || o.version! > 3) @ValidateIf((o) => o.version === null || o.version > 3)
@IsEnum(StructuredBoolean) @IsEnum(StructuredBoolean)
uploadComplete!: StructuredBoolean; uploadComplete!: StructuredBoolean;
@Expose({ name: UploadHeader.UploadIncomplete }) @Expose({ name: UploadHeader.UploadIncomplete })
@ValidateIf((o) => o.version !== null && o.version! <= 3) @ValidateIf((o) => o.version !== null && o.version <= 3)
@IsEnum(StructuredBoolean) @IsEnum(StructuredBoolean)
uploadIncomplete!: StructuredBoolean; uploadIncomplete!: StructuredBoolean;
@@ -97,19 +87,26 @@ export class BaseUploadHeadersDto extends BaseRufhHeadersDto {
export class StartUploadDto extends BaseUploadHeadersDto { export class StartUploadDto extends BaseUploadHeadersDto {
@Expose({ name: ImmichHeader.AssetData }) @Expose({ name: ImmichHeader.AssetData })
// @ValidateNested() @ValidateNested()
// @IsObject()
@Type(() => UploadAssetDataDto)
@Transform(({ value }) => { @Transform(({ value }) => {
if (!value) { if (!value) {
return null; throw new BadRequestException(`${ImmichHeader.AssetData} header is required`);
} }
const json = Buffer.from(value, 'base64').toString('utf-8');
try { try {
return JSON.parse(json); const dict = parseDictionary(value);
} catch { return plainToInstance(UploadAssetDataDto, {
throw new BadRequestException(`${ImmichHeader.AssetData} must be valid base64-encoded JSON`); 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; assetData!: UploadAssetDataDto;

View File

@@ -9,8 +9,16 @@ import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto } from 'src/dtos/upload.dto'; import { GetUploadStatusDto, ResumeUploadDto, StartUploadDto } from 'src/dtos/upload.dto';
import { AssetStatus, AssetType, AssetVisibility, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum'; import {
import { AuthenticatedRequest } from 'src/middleware/auth.guard'; AssetMetadataKey,
AssetStatus,
AssetType,
AssetVisibility,
JobName,
JobStatus,
QueueName,
StorageFolder,
} from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { isAssetChecksumConstraint } from 'src/utils/database'; import { isAssetChecksumConstraint } from 'src/utils/database';
@@ -21,12 +29,12 @@ export const MAX_RUFH_INTEROP_VERSION = 8;
@Injectable() @Injectable()
export class AssetUploadService extends BaseService { 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)}`); this.logger.verboseFn(() => `Starting upload: ${JSON.stringify(dto)}`);
const { isComplete, assetData, uploadLength, contentLength, version } = dto; const { isComplete, assetData, uploadLength, contentLength, version } = dto;
const assetId = this.cryptoRepository.randomUUID(); 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 extension = extname(assetData.filename);
const path = join(folder, `${assetId}${extension}`); const path = join(folder, `${assetId}${extension}`);
const type = mimeTypes.assetType(path); const type = mimeTypes.assetType(path);
@@ -35,13 +43,13 @@ export class AssetUploadService extends BaseService {
throw new BadRequestException(`${assetData.filename} is an unsupported file type`); throw new BadRequestException(`${assetData.filename} is an unsupported file type`);
} }
this.validateQuota(req.auth, uploadLength ?? contentLength); this.validateQuota(auth, uploadLength ?? contentLength);
try { try {
await this.assetRepository.createWithMetadata( await this.assetRepository.createWithMetadata(
{ {
id: assetId, id: assetId,
ownerId: req.auth.user.id, ownerId: auth.user.id,
libraryId: null, libraryId: null,
checksum: dto.checksum, checksum: dto.checksum,
originalPath: path, originalPath: path,
@@ -58,7 +66,7 @@ export class AssetUploadService extends BaseService {
status: AssetStatus.Partial, status: AssetStatus.Partial,
}, },
uploadLength, uploadLength,
assetData.metadata, assetData.iCloudId ? [{ key: AssetMetadataKey.MobileApp, value: { iCloudId: assetData.iCloudId } }] : undefined,
); );
} catch (error: any) { } catch (error: any) {
if (!isAssetChecksumConstraint(error)) { if (!isAssetChecksumConstraint(error)) {
@@ -67,7 +75,7 @@ export class AssetUploadService extends BaseService {
return; 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) { if (!duplicate) {
res.status(500).send('Error locating duplicate for checksum constraint'); res.status(500).send('Error locating duplicate for checksum constraint');
return; return;
@@ -126,12 +134,12 @@ export class AssetUploadService extends BaseService {
await new Promise((resolve) => writeStream.on('close', resolve)); 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)}`); this.logger.verboseFn(() => `Resuming upload for ${id}: ${JSON.stringify(dto)}`);
const { isComplete, uploadLength, uploadOffset, contentLength, version } = dto; const { isComplete, uploadLength, uploadOffset, contentLength, version } = dto;
this.setCompleteHeader(res, version, false);
return this.databaseRepository.withUuidLock(id, async () => { 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) { if (!completionData) {
res.status(404).send('Asset not found'); res.status(404).send('Asset not found');
return; return;
@@ -139,30 +147,25 @@ export class AssetUploadService extends BaseService {
const { fileModifiedAt, path, status, checksum: providedChecksum, size } = completionData; const { fileModifiedAt, path, status, checksum: providedChecksum, size } = completionData;
if (status !== AssetStatus.Partial) { if (status !== AssetStatus.Partial) {
this.setCompleteHeader(res, version, false);
return this.sendAlreadyCompletedProblem(res); return this.sendAlreadyCompletedProblem(res);
} }
if (uploadLength && size && size !== uploadLength) { if (uploadLength && size && size !== uploadLength) {
this.setCompleteHeader(res, version, false);
return this.sendInconsistentLengthProblem(res); return this.sendInconsistentLengthProblem(res);
} }
const expectedOffset = await this.getCurrentOffset(path); const expectedOffset = await this.getCurrentOffset(path);
if (expectedOffset !== uploadOffset) { if (expectedOffset !== uploadOffset) {
this.setCompleteHeader(res, version, false);
return this.sendOffsetMismatchProblem(res, expectedOffset, uploadOffset); return this.sendOffsetMismatchProblem(res, expectedOffset, uploadOffset);
} }
const newLength = uploadOffset + contentLength; const newLength = uploadOffset + contentLength;
if (uploadLength !== undefined && newLength > uploadLength) { if (uploadLength !== undefined && newLength > uploadLength) {
this.setCompleteHeader(res, version, false);
res.status(400).send('Upload would exceed declared length'); res.status(400).send('Upload would exceed declared length');
return; return;
} }
if (contentLength === 0 && !isComplete) { if (contentLength === 0 && !isComplete) {
this.setCompleteHeader(res, version, false);
res.status(204).setHeader('Upload-Offset', expectedOffset.toString()).send(); res.status(204).setHeader('Upload-Offset', expectedOffset.toString()).send();
return; 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 () => { return this.databaseRepository.withUuidLock(assetId, async () => {
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id); const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
if (!asset) { if (!asset) {
response.status(404).send('Asset not found'); res.status(404).send('Asset not found');
return; return;
} }
if (asset.status !== AssetStatus.Partial) { if (asset.status !== AssetStatus.Partial) {
return this.sendAlreadyCompletedProblem(response); return this.sendAlreadyCompletedProblem(res);
} }
await this.onCancel(assetId, asset.path); 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> { 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 () => { return this.databaseRepository.withUuidLock(id, async () => {
const asset = await this.assetRepository.getCompletionMetadata(id, auth.user.id); const asset = await this.assetRepository.getCompletionMetadata(id, auth.user.id);
if (!asset) { if (!asset) {
@@ -333,7 +337,7 @@ export class AssetUploadService extends BaseService {
socket.write( socket.write(
'HTTP/1.1 104 Upload Resumption Supported\r\n' + 'HTTP/1.1 104 Upload Resumption Supported\r\n' +
`Location: ${location}\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`, `Upload-Draft-Interop-Version: ${interopVersion}\r\n\r\n`,
); );
} }