mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 17:23:21 +03:00
add note about RFC 9651
authdto remove excess logs use structured dictionary
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user