diff --git a/e2e/src/api/specs/asset-upload.e2e-spec.ts b/e2e/src/api/specs/asset-upload.e2e-spec.ts index 63e2d02808..83264bcce8 100644 --- a/e2e/src/api/specs/asset-upload.e2e-spec.ts +++ b/e2e/src/api/specs/asset-upload.e2e-spec.ts @@ -1,19 +1,25 @@ -import { LoginResponseDto } from '@immich/sdk'; +import { getMyUser, LoginResponseDto } from '@immich/sdk'; import { createHash, randomBytes } from 'node:crypto'; import { createUserDto } from 'src/fixtures'; -import { app, baseUrl, utils } from 'src/utils'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, baseUrl, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; -describe('/upload (RUFH compliance)', () => { +describe('/upload', () => { let admin: LoginResponseDto; let user: LoginResponseDto; + let quotaUser: LoginResponseDto; + let cancelQuotaUser: LoginResponseDto; + let base64Metadata: string; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - user = await utils.userSetup(admin.accessToken, createUserDto.create('upload-test')); + 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', @@ -26,7 +32,24 @@ describe('/upload (RUFH compliance)', () => { ).toString('base64'); }); - describe('Upload Creation (Section 4.2)', () => { + describe('startUpload', () => { + it('should require auth', async () => { + const content = randomBytes(1024); + + const { status, headers } = await request(app) + .post('/upload') + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(content); + + expect(status).toBe(401); + expect(headers['location']).toBeUndefined(); + }); + it('should create a complete upload with Upload-Complete: ?1', async () => { const content = randomBytes(1024); @@ -45,6 +68,68 @@ describe('/upload (RUFH compliance)', () => { expect(headers['upload-complete']).toBe('?1'); }); + it('should create a complete upload with Upload-Incomplete: ?0 if version is 3', async () => { + const content = randomBytes(1024); + + const { status, headers } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '3') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Incomplete', '?0') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(content); + + expect(status).toBe(200); + expect(headers['upload-incomplete']).toBe('?0'); + }); + + it('should reject when Upload-Complete: ?1 with mismatching Content-Length and Upload-Length', async () => { + const content = randomBytes(1000); + + const { status, headers, body } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Upload-Length', '2000') + .set('Content-Length', '1000') + .send(content); + + expect(status).toBe(400); + expect(headers['content-type']).toBe('application/problem+json; charset=utf-8'); + expect(body).toEqual({ + type: 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length', + title: 'inconsistent length values for upload', + }); + }); + + it('should require upload-length', async () => { + const content = randomBytes(1024); + + const { status, headers, body } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .send(content); + + expect(status).toBe(400); + expect(headers['location']).toBeUndefined(); + expect(body).toEqual( + expect.objectContaining({ + message: ['uploadLength must be an integer number', 'uploadLength must not be less than 0'], + }), + ); + }); + it('should create an incomplete upload with Upload-Complete: ?0', async () => { const partialContent = randomBytes(512); @@ -61,10 +146,574 @@ describe('/upload (RUFH compliance)', () => { expect(status).toBe(201); expect(headers['location']).toMatch(/^\/api\/upload\/[a-zA-Z0-9\-]+$/); + expect(headers['upload-complete']).toBe('?0'); + }); + + it('should create an incomplete upload with Upload-Incomplete: ?1 if version is 3', async () => { + const partialContent = randomBytes(512); + + const { status, headers } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '3') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`) + .set('Upload-Incomplete', '?1') + .set('Content-Length', '512') + .set('Upload-Length', '513') + .send(partialContent); + + expect(status).toBe(201); + expect(headers['location']).toMatch(/^\/api\/upload\/[a-zA-Z0-9\-]+$/); + expect(headers['upload-incomplete']).toBe('?1'); + }); + + it('should reject invalid checksum', async () => { + const content = randomBytes(1024); + + const { status, headers, body } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:INVALID:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(content); + + expect(status).toBe(400); + expect(headers['location']).toBeUndefined(); + expect(body).toEqual(expect.objectContaining({ message: 'Invalid repr-digest header' })); + }); + + it('should reject attempt to upload completed asset', async () => { + const content = randomBytes(1024); + + const firstRequest = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(content); + + expect(firstRequest.status).toBe(200); + expect(firstRequest.headers['upload-complete']).toBe('?1'); + + const secondRequest = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(content); + + expect(secondRequest.status).toBe(400); + expect(secondRequest.headers['content-type']).toBe('application/problem+json; charset=utf-8'); + expect(secondRequest.body).toEqual({ + type: 'https://iana.org/assignments/http-problem-types#completed-upload', + title: 'upload is already completed', + }); + }); + + it('should reject attempt to start upload of existing incomplete asset', async () => { + const content = randomBytes(1024); + + const firstRequest = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(content); + + expect(firstRequest.status).toBe(201); + expect(firstRequest.headers['upload-complete']).toBe('?0'); + expect(firstRequest.headers['location']).toMatch(/^\/api\/upload\/[a-zA-Z0-9\-]+$/); + + const secondRequest = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(); + + expect(secondRequest.status).toBe(400); + expect(secondRequest.headers['location']).toEqual(firstRequest.headers['location']); + expect(secondRequest.text).toEqual('Incomplete asset already exists'); + }); + + it('should reject asset with mismatching checksum', async () => { + const content = randomBytes(1024); + + const { status, headers, text } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update('').digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1024') + .send(content); + + expect(status).toBe(460); + expect(headers['location']).toBeUndefined(); + expect(text).toBe('File on server does not match provided checksum'); + }); + + it('should update the used quota', async () => { + const content = randomBytes(500); + + const { status, headers } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '500') + .send(content); + + expect(status).toBe(200); + expect(headers['upload-complete']).toBe('?1'); + + const userData = await getMyUser({ headers: asBearerAuth(quotaUser.accessToken) }); + + expect(userData).toEqual(expect.objectContaining({ quotaUsageInBytes: 500 })); + }); + + it('should not upload an asset if it would exceed the quota', async () => { + const { body, status } = await request(app) + .post('/assets') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .attach('assetData', randomBytes(13), 'example.jpg'); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!')); }); }); - describe('Offset Retrieval (Section 4.3)', () => { + describe('resumeUpload', () => { + let uploadResource: string; + let chunks: Buffer[]; + + beforeAll(async () => { + // Create an incomplete upload + chunks = [randomBytes(750), randomBytes(500), randomBytes(1500)]; + const fullContent = Buffer.concat(chunks); + const response = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(fullContent).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Upload-Length', '2750') + .send(chunks[0]); + + uploadResource = response.headers['location']; + }); + + it('should require auth', async () => { + const { status, headers } = await request(baseUrl) + .patch(uploadResource) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', '1250') + .set('Upload-Complete', '?1') + .set('Content-Type', 'application/partial-upload') + .send(chunks[2]); + + expect(status).toBe(401); + expect(headers['upload-complete']).toBeUndefined(); + }); + + it("should reject upload to another user's asset", async () => { + const { status, headers } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${admin.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', '1250') + .set('Upload-Complete', '?1') + .set('Content-Type', 'application/partial-upload') + .send(chunks[2]); + + expect(status).toBe(404); + expect(headers['upload-complete']).toBeUndefined(); + }); + + it('should append data with correct offset', async () => { + const { status, headers } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', chunks[0].length.toString()) + .set('Upload-Complete', '?0') + .set('Content-Type', 'application/partial-upload') + .send(chunks[1]); + + expect(status).toBe(204); + expect(headers['upload-complete']).toBe('?0'); + + const headResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + + expect(headResponse.headers['upload-offset']).toBe('1250'); + }); + + it('should reject append with different upload length than before', async () => { + const { status, headers, body } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', '1250') + .set('Upload-Complete', '?0') + .set('Upload-Length', '1250') + .set('Content-Type', 'application/partial-upload') + .send(); + + expect(status).toBe(400); + expect(headers['content-type']).toBe('application/problem+json; charset=utf-8'); + expect(body).toEqual({ + type: 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length', + title: 'inconsistent length values for upload', + }); + }); + + it('should reject append with mismatching length', async () => { + const wrongOffset = 100; + + const { status, headers, body } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', wrongOffset.toString()) + .set('Upload-Complete', '?0') + .set('Content-Type', 'application/partial-upload') + .send(randomBytes(100)); + + expect(status).toBe(409); + expect(headers['upload-offset']).toBe('1250'); + expect(headers['content-type']).toBe('application/problem+json; charset=utf-8'); + expect(body).toEqual({ + type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset', + title: 'offset from request does not match offset of resource', + 'expected-offset': 1250, + 'provided-offset': wrongOffset, + }); + }); + + it('should require application/partial-upload content type if version is at least 6', async () => { + const { status, body } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '6') + .set('Upload-Offset', '1250') + .set('Upload-Complete', '?0') + .set('Content-Type', 'application/octet-stream') + .send(randomBytes(100)); + + expect(status).toBe(400); + expect(body).toEqual( + expect.objectContaining({ message: ['contentType must be equal to application/partial-upload'] }), + ); + }); + + it('should allow non-application/partial-upload content type if version is less than 6', async () => { + const { status, headers } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '3') + .set('Upload-Offset', '1250') + .set('Upload-Incomplete', '?1') + .set('Content-Type', 'application/octet-stream') + .send(); + + expect(status).toBe(204); + expect(headers['upload-offset']).toBe('1250'); + }); + + it('should complete upload with Upload-Complete: ?1', async () => { + const headResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + + const offset = parseInt(headResponse.headers['upload-offset']); + expect(offset).toBe(1250); + + const { status, headers } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', offset.toString()) + .set('Upload-Complete', '?1') + .set('Content-Type', 'application/partial-upload') + .send(chunks[2]); + + expect(status).toBe(200); + expect(headers['upload-complete']).toBe('?1'); + }); + + it('should reject append to completed upload', async () => { + const { status, headers, body } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', '2750') + .set('Upload-Complete', '?0') + .set('Content-Type', 'application/partial-upload') + .send(randomBytes(100)); + + expect(status).toBe(400); + expect(headers['content-type']).toBe('application/problem+json; charset=utf-8'); + expect(body).toEqual({ + type: 'https://iana.org/assignments/http-problem-types#completed-upload', + title: 'upload is already completed', + }); + }); + + it('should handle interrupted initial upload and resume', async () => { + const totalContent = randomBytes(5000); + const firstPart = totalContent.subarray(0, 2000); + + const initialResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(totalContent).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Upload-Length', '5000') + .send(firstPart); + expect(initialResponse.status).toBe(201); + const uploadResource = initialResponse.headers['location']; + + const offsetResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + expect(offsetResponse.headers['upload-offset']).toBe('2000'); + + const remainingContent = totalContent.subarray(2000); + const resumeResponse = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', '2000') + .set('Upload-Complete', '?1') + .set('Content-Type', 'application/partial-upload') + .send(remainingContent); + + expect(resumeResponse.status).toBe(200); + expect(resumeResponse.headers['upload-complete']).toBe('?1'); + }); + + it('should handle multiple interruptions and resumptions', async () => { + const chunks = [randomBytes(2000), randomBytes(3000), randomBytes(5000)]; + const hash = createHash('sha1'); + chunks.forEach((chunk) => hash.update(chunk)); + + const createResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${hash.digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Upload-Length', '10000') + .send(chunks[0]); + + const uploadResource = createResponse.headers['location']; + let currentOffset = 2000; + + let response = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', currentOffset.toString()) + .set('Upload-Complete', '?0') + .set('Content-Type', 'application/partial-upload') + .send(chunks[1]); + + expect(response.status).toBe(204); + currentOffset += 3000; + + const offsetCheck = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + + expect(offsetCheck.headers['upload-offset']).toBe('5000'); + + response = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', currentOffset.toString()) + .set('Upload-Complete', '?1') + .set('Content-Type', 'application/partial-upload') + .send(chunks[2]); + + expect(response.status).toBe(200); + expect(response.headers['upload-complete']).toBe('?1'); + }); + }); + + describe('cancelUpload', () => { + let uploadResource: string; + + beforeAll(async () => { + const content = randomBytes(200); + // Create an incomplete upload + const response = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Upload-Length', '200') + .send(content); + + uploadResource = response.headers['location']; + }); + + it('should require auth', async () => { + const { status } = await request(baseUrl).delete(uploadResource); + expect(status).toBe(401); + + const headResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + expect(headResponse.status).toBe(204); + }); + + it("should reject attempt to delete another user's asset", async () => { + const { status } = await request(baseUrl) + .delete(uploadResource) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(404); + + const headResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + expect(headResponse.status).toBe(204); + }); + + it('should reject attempt to delete completed asset', async () => { + const content = randomBytes(1000); + const postResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Content-Type', 'image/jpeg') + .set('Upload-Length', '1000') + .send(content); + expect(postResponse.status).toBe(201); + expect(postResponse.headers['upload-complete']).toBe('?0'); + const location = postResponse.headers['location']; + expect(location).toBeDefined(); + + const patchResponse = await request(baseUrl) + .patch(location) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('Upload-Offset', '1000') + .set('Upload-Complete', '?1') + .set('Content-Type', 'application/partial-upload') + .send(); + expect(patchResponse.status).toBe(200); + expect(patchResponse.headers['upload-complete']).toBe('?1'); + + const deleteResponse = await request(baseUrl).delete(location).set('Authorization', `Bearer ${user.accessToken}`); + expect(deleteResponse.status).toBe(400); + expect(deleteResponse.headers['content-type']).toBe('application/problem+json; charset=utf-8'); + expect(deleteResponse.body).toEqual({ + type: 'https://iana.org/assignments/http-problem-types#completed-upload', + title: 'upload is already completed', + }); + + const headResponse = await request(baseUrl) + .head(location) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + expect(headResponse.status).toBe(204); + }); + + it('should cancel upload with DELETE request', async () => { + const { status } = await request(baseUrl) + .delete(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + + const headResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + expect(headResponse.status).toBe(404); + }); + + it('should update quota usage', async () => { + const content = randomBytes(200); + const response = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`) + .set('Upload-Draft-Interop-Version', '8') + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Upload-Length', '200') + .send(content); + const uploadResource = response.headers['location']; + + const userDataBefore = await getMyUser({ headers: asBearerAuth(cancelQuotaUser.accessToken) }); + expect(userDataBefore).toEqual(expect.objectContaining({ quotaUsageInBytes: 200 })); + + const { status } = await request(baseUrl) + .delete(uploadResource) + .set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`); + expect(status).toBe(204); + + const userDataAfter = await getMyUser({ headers: asBearerAuth(cancelQuotaUser.accessToken) }); + expect(userDataAfter).toEqual(expect.objectContaining({ quotaUsageInBytes: 0 })); + + const headResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${cancelQuotaUser.accessToken}`) + .set('Upload-Draft-Interop-Version', '8'); + expect(headResponse.status).toBe(404); + }); + }); + + describe('getUploadStatus', () => { let uploadResource: string; beforeAll(async () => { @@ -84,6 +733,27 @@ describe('/upload (RUFH compliance)', () => { uploadResource = headers['location']; }); + it('should require auth', async () => { + const { status, headers } = await request(baseUrl).head(uploadResource).set('Upload-Draft-Interop-Version', '8'); + + expect(status).toBe(401); + expect(headers['upload-offset']).toBeUndefined(); + expect(headers['upload-complete']).toBeUndefined(); + expect(headers['upload-limit']).toBeUndefined(); + }); + + it("should disallow fetching another user's asset", async () => { + const { status, headers } = await request(baseUrl) + .head(uploadResource) + .set('Upload-Draft-Interop-Version', '8') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(404); + expect(headers['upload-offset']).toBeUndefined(); + expect(headers['upload-complete']).toBeUndefined(); + expect(headers['upload-limit']).toBeUndefined(); + }); + it('should retrieve upload offset with HEAD request', async () => { const { status, headers } = await request(baseUrl) .head(uploadResource) @@ -116,278 +786,12 @@ describe('/upload (RUFH compliance)', () => { }); }); - describe('Upload Append (Section 4.4)', () => { - let uploadResource: string; - let chunks: Buffer[]; - - beforeAll(async () => { - // Create an incomplete upload - chunks = [randomBytes(750), randomBytes(500), randomBytes(1500)]; - const fullContent = Buffer.concat(chunks); - const response = await request(app) - .post('/upload') - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('X-Immich-Asset-Data', base64Metadata) - .set('Repr-Digest', `sha=:${createHash('sha1').update(fullContent).digest('base64')}:`) - .set('Upload-Complete', '?0') - .set('Upload-Length', '2750') - .send(chunks[0]); - - uploadResource = response.headers['location']; - }); - - it('should append data with correct offset', async () => { - const { status, headers } = await request(baseUrl) - .patch(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('Upload-Offset', chunks[0].length.toString()) - .set('Upload-Complete', '?0') - .set('Content-Type', 'application/partial-upload') - .send(chunks[1]); - - expect(status).toBe(204); - expect(headers['upload-complete']).toBe('?0'); - - const headResponse = await request(baseUrl) - .head(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8'); - - expect(headResponse.headers['upload-offset']).toBe('1250'); - }); - - it('should reject append with mismatching offset (409 Conflict)', async () => { - const wrongOffset = 100; - - const { status, headers, body } = await request(baseUrl) - .patch(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('Upload-Offset', wrongOffset.toString()) - .set('Upload-Complete', '?0') - .set('Content-Type', 'application/partial-upload') - .send(randomBytes(100)); - - expect(status).toBe(409); - expect(headers['upload-offset']).toBe('1250'); - expect(body).toEqual({ - type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset', - title: 'offset from request does not match offset of resource', - 'expected-offset': 1250, - 'provided-offset': wrongOffset, - }); - }); - - it('should complete upload with Upload-Complete: ?1', async () => { - // Get current offset first - const headResponse = await request(baseUrl) - .head(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8'); - - const offset = parseInt(headResponse.headers['upload-offset']); - expect(offset).toBe(1250); - - const { status, headers } = await request(baseUrl) - .patch(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('Upload-Offset', offset.toString()) - .set('Upload-Complete', '?1') - .set('Content-Type', 'application/partial-upload') - .send(chunks[2]); - - expect(status).toBe(200); - expect(headers['upload-complete']).toBe('?1'); - expect(headers['upload-offset']).toBe('2750'); - }); - - it('should reject append to completed upload', async () => { - const { status, body } = await request(baseUrl) - .patch(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('Upload-Offset', '2750') - .set('Upload-Complete', '?0') - .set('Content-Type', 'application/partial-upload') - .send(randomBytes(100)); - - expect(status).toBe(400); - expect(body).toEqual({ - type: 'https://iana.org/assignments/http-problem-types#completed-upload', - title: 'upload is already completed', - }); - }); - }); - - describe('Upload Cancellation (Section 4.5)', () => { - let uploadResource: string; - - beforeAll(async () => { - const content = randomBytes(200); - // Create an incomplete upload - const response = await request(app) - .post('/upload') - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('X-Immich-Asset-Data', base64Metadata) - .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) - .set('Upload-Complete', '?0') - .set('Upload-Length', '200') - .send(content); - - uploadResource = response.headers['location']; - }); - - it('should cancel upload with DELETE request', async () => { - const { status } = await request(baseUrl) - .delete(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`); - - expect(status).toBe(204); - - // Verify resource is no longer accessible - const headResponse = await request(baseUrl) - .head(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8'); - - expect(headResponse.status).toBe(404); - }); - }); - - describe('Interrupted Upload Scenarios', () => { - it('should handle interrupted initial upload and resume', async () => { - // Simulate interrupted upload by sending partial content - const totalContent = randomBytes(5000); - const firstPart = totalContent.subarray(0, 2000); - - // Initial upload with interruption - const initialResponse = await request(app) - .post('/upload') - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('X-Immich-Asset-Data', base64Metadata) - .set('Repr-Digest', `sha=:${createHash('sha1').update(totalContent).digest('base64')}:`) - .set('Upload-Complete', '?0') // Indicate incomplete - .set('Upload-Length', '5000') - .send(firstPart); - - expect(initialResponse.status).toBe(201); - const uploadResource = initialResponse.headers['location']; - - // Check offset after interruption - const offsetResponse = await request(baseUrl) - .head(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8'); - - expect(offsetResponse.headers['upload-offset']).toBe('2000'); - - // Resume upload - const remainingContent = totalContent.subarray(2000); - const resumeResponse = await request(baseUrl) - .patch(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('Upload-Offset', '2000') - .set('Upload-Complete', '?1') - .set('Content-Type', 'application/partial-upload') - .send(remainingContent); - - expect(resumeResponse.status).toBe(200); - expect(resumeResponse.headers['upload-complete']).toBe('?1'); - }); - - it('should handle multiple interruptions and resumptions', async () => { - const chunks = [randomBytes(2000), randomBytes(3000), randomBytes(5000)]; - const hash = createHash('sha1'); - chunks.forEach((chunk) => hash.update(chunk)); - - // Create initial upload - const createResponse = await request(app) - .post('/upload') - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('X-Immich-Asset-Data', base64Metadata) - .set('Repr-Digest', `sha=:${hash.digest('base64')}:`) - .set('Upload-Complete', '?0') - .set('Upload-Length', '10000') - .send(chunks[0]); - - const uploadResource = createResponse.headers['location']; - let currentOffset = 2000; - - // First resumption - let response = await request(baseUrl) - .patch(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('Upload-Offset', currentOffset.toString()) - .set('Upload-Complete', '?0') - .set('Content-Type', 'application/partial-upload') - .send(chunks[1]); - - expect(response.status).toBe(204); - currentOffset += 3000; - - // Verify offset - const offsetCheck = await request(baseUrl) - .head(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8'); - - expect(offsetCheck.headers['upload-offset']).toBe('5000'); - - // Final resumption - response = await request(baseUrl) - .patch(uploadResource) - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('Upload-Offset', currentOffset.toString()) - .set('Upload-Complete', '?1') - .set('Content-Type', 'application/partial-upload') - .send(chunks[2]); - - expect(response.status).toBe(200); - expect(response.headers['upload-complete']).toBe('?1'); - }); - }); - - describe('Inconsistent Length Scenarios', () => { - it('should reject when Upload-Complete: ?1 with mismatching Content-Length and Upload-Length', async () => { - const content = randomBytes(1000); - - const { status, body } = await request(app) - .post('/upload') - .set('Authorization', `Bearer ${user.accessToken}`) - .set('Upload-Draft-Interop-Version', '8') - .set('X-Immich-Asset-Data', base64Metadata) - .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) - .set('Upload-Complete', '?1') - .set('Upload-Length', '2000') // Doesn't match content length - .set('Content-Length', '1000') - .send(content); - - expect(status).toBe(400); - expect(body).toEqual({ - type: 'https://iana.org/assignments/http-problem-types#inconsistent-upload-length', - title: 'inconsistent length values for upload', - }); - }); - }); - - describe('Limit Enforcement', () => { - it('should include Upload-Limit in OPTIONS response', async () => { - const { status, headers } = await request(app) - .options('/upload') - .set('Authorization', `Bearer ${user.accessToken}`); + describe('getUploadOptions', () => { + it('should include upload limits in response', async () => { + const { status, headers } = await request(app).options('/upload'); expect(status).toBe(204); expect(headers['upload-limit']).toEqual('min-size=0'); }); }); }); - diff --git a/server/src/controllers/asset-upload.controller.ts b/server/src/controllers/asset-upload.controller.ts index 827c981fa1..33e97079de 100644 --- a/server/src/controllers/asset-upload.controller.ts +++ b/server/src/controllers/asset-upload.controller.ts @@ -1,4 +1,18 @@ -import { BadRequestException, Controller, Delete, Head, Options, Param, Patch, Post, Req, Res } from '@nestjs/common'; +import { + BadRequestException, + Controller, + Delete, + Head, + Header, + HttpCode, + HttpStatus, + Options, + Param, + Patch, + Post, + Req, + Res, +} from '@nestjs/common'; import { ApiHeader, ApiTags } from '@nestjs/swagger'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; @@ -91,16 +105,15 @@ export class AssetUploadController { } @Options() - @Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) - getUploadOptions(@Res() res: Response) { - return this.service.getUploadOptions(res); - } + @HttpCode(HttpStatus.NO_CONTENT) + @Header('Upload-Limit', 'min-size=0') + getUploadOptions() {} private getDto(cls: new () => T, headers: IncomingHttpHeaders): T { const dto = plainToInstance(cls, headers, { excludeExtraneousValues: true }); const errors = validateSync(dto); if (errors.length > 0) { - const constraints = errors.map((e) => (e.constraints ? Object.values(e.constraints).join(', ') : '')).join('; '); + const constraints = errors.flatMap((e) => (e.constraints ? Object.values(e.constraints) : [])); console.warn('Upload DTO validation failed:', JSON.stringify(errors, null, 2)); throw new BadRequestException(constraints); } diff --git a/server/src/dtos/upload.dto.ts b/server/src/dtos/upload.dto.ts index 67e6f851eb..1f70afff85 100644 --- a/server/src/dtos/upload.dto.ts +++ b/server/src/dtos/upload.dto.ts @@ -84,22 +84,15 @@ export class BaseUploadHeadersDto extends BaseRufhHeadersDto { contentLength!: number; @Expose({ name: UploadHeader.UploadComplete }) - @ValidateIf((o) => o.requestInterop !== null && o.requestInterop! <= 3) + @ValidateIf((o) => o.version === null || o.version! > 3) @IsEnum(StructuredBoolean) uploadComplete!: StructuredBoolean; @Expose({ name: UploadHeader.UploadIncomplete }) - @ValidateIf((o) => o.requestInterop === null || o.requestInterop! > 3) + @ValidateIf((o) => o.version !== null && o.version! <= 3) @IsEnum(StructuredBoolean) uploadIncomplete!: StructuredBoolean; - @Expose({ name: UploadHeader.UploadLength }) - @Min(0) - @IsInt() - @Type(() => Number) - @Optional() - uploadLength?: number; - get isComplete(): boolean { if (this.version <= 3) { return this.uploadIncomplete === StructuredBoolean.False; @@ -134,7 +127,7 @@ export class StartUploadDto extends BaseUploadHeadersDto { } const checksum = parseDictionary(value).get('sha')?.[0]; - if (checksum instanceof ArrayBuffer) { + if (checksum instanceof ArrayBuffer && checksum.byteLength === 20) { return Buffer.from(checksum); } throw new BadRequestException(`Invalid ${UploadHeader.ReprDigest} header`); @@ -145,14 +138,21 @@ export class StartUploadDto extends BaseUploadHeadersDto { @Min(0) @IsInt() @Type(() => Number) - declare uploadLength: number; + uploadLength!: number; } export class ResumeUploadDto extends BaseUploadHeadersDto { @Expose({ name: 'content-type' }) - @ValidateIf((o) => o.requestInterop !== null && o.requestInterop >= 6) + @ValidateIf((o) => o.version && o.version >= 6) @Equals('application/partial-upload') - contentType!: number | null; + contentType!: string; + + @Expose({ name: UploadHeader.UploadLength }) + @Min(0) + @IsInt() + @Type(() => Number) + @Optional() + uploadLength?: number; @Expose({ name: UploadHeader.UploadOffset }) @Min(0) diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index a0caef3f61..c537aff9ac 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -256,27 +256,26 @@ export class AssetRepository { } createWithMetadata(asset: Insertable & { id: string }, size: number, metadata?: AssetMetadataItem[]) { - if (!metadata || metadata.length === 0) { - return this.db.insertInto('asset').values(asset).execute(); - } - - return this.db - .with('asset', (qb) => qb.insertInto('asset').values(asset).returning('id')) + let query = this.db + .with('asset', (qb) => qb.insertInto('asset').values(asset).returning(['id', 'ownerId'])) .with('exif', (qb) => qb .insertInto('asset_exif') .columns(['assetId', 'fileSizeInByte']) .expression((eb) => eb.selectFrom('asset').select(['asset.id', eb.val(size).as('fileSizeInByte')])), - ) - .with('user', (qb) => - qb - .updateTable('user') - .from('asset') - .set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` }) - .whereRef('user.id', '=', 'asset.ownerId'), - ) - .insertInto('asset_metadata') - .values(metadata.map(({ key, value }) => ({ assetId: asset.id, key, value }))) + ); + + if (metadata && metadata.length > 0) { + (query as any) = query.with('metadata', (qb) => + qb.insertInto('asset_metadata').values(metadata.map(({ key, value }) => ({ assetId: asset.id, key, value }))), + ); + } + + return query + .updateTable('user') + .from('asset') + .set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` }) + .whereRef('user.id', '=', 'asset.ownerId') .execute(); } @@ -290,12 +289,23 @@ export class AssetRepository { .executeTakeFirst(); } - setCompleteWithSize(assetId: string) { + setComplete(assetId: string) { return this.db .updateTable('asset') .set({ status: AssetStatus.Active }) - .where('asset.id', '=', assetId) - .where('asset.status', '=', sql.lit(AssetStatus.Partial)) + .where('id', '=', assetId) + .where('status', '=', sql.lit(AssetStatus.Partial)) + .execute(); + } + + async removeAndDecrementQuota(id: string): Promise { + await this.db + .with('asset_exif', (qb) => qb.selectFrom('asset_exif').where('assetId', '=', id).select('fileSizeInByte')) + .with('asset', (qb) => qb.deleteFrom('asset').where('id', '=', id).returning('ownerId')) + .updateTable('user') + .from(['asset_exif', 'asset']) + .set({ quotaUsageInBytes: sql`"quotaUsageInBytes" - "fileSizeInByte"` }) + .whereRef('user.id', '=', 'asset.ownerId') .execute(); } diff --git a/server/src/services/asset-upload.service.ts b/server/src/services/asset-upload.service.ts index eaa6fa655d..d8034235b5 100644 --- a/server/src/services/asset-upload.service.ts +++ b/server/src/services/asset-upload.service.ts @@ -20,9 +20,6 @@ export class AssetUploadService extends BaseService { async startUpload(req: AuthenticatedRequest, res: Response, dto: StartUploadDto): Promise { this.logger.verboseFn(() => `Starting upload: ${JSON.stringify(dto)}`); const { isComplete, assetData, uploadLength, contentLength, version } = dto; - if (isComplete && uploadLength && uploadLength !== contentLength) { - return this.sendInconsistentLengthProblem(res); - } const assetId = this.cryptoRepository.randomUUID(); const folder = StorageCore.getNestedFolder(StorageFolder.Upload, req.auth.user.id, assetId); @@ -71,7 +68,7 @@ export class AssetUploadService extends BaseService { return this.sendAlreadyCompletedProblem(res); } const location = `/api/upload/${duplicate.id}`; - res.status(201).setHeader('Location', location).setHeader('Upload-Limit', 'min-size=0').send(); + res.status(400).setHeader('Location', location).send('Incomplete asset already exists'); return; } this.logger.error(`Error creating upload asset record: ${error.message}`); @@ -79,6 +76,10 @@ export class AssetUploadService extends BaseService { return; } + if (isComplete && uploadLength && uploadLength !== contentLength) { + return this.sendInconsistentLengthProblem(res); + } + const location = `/api/upload/${assetId}`; if (version <= MAX_RUFH_INTEROP_VERSION) { this.sendInterimResponse(res, location, version); @@ -98,7 +99,7 @@ export class AssetUploadService extends BaseService { writeStream.on('finish', () => { this.setCompleteHeader(res, dto.version, isComplete); if (!isComplete) { - return res.status(201).send(); + return res.status(201).set('Location', location).setHeader('Upload-Limit', 'min-size=0').send(); } this.logger.log(`Finished upload to ${path}`); if (dto.checksum.compare(checksumBuffer!) !== 0) { @@ -183,6 +184,40 @@ export class AssetUploadService extends BaseService { }); } + cancelUpload(auth: AuthDto, assetId: string, response: Response): Promise { + 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'); + return; + } + if (asset.status !== AssetStatus.Partial) { + return this.sendAlreadyCompletedProblem(response); + } + await this.onCancel(assetId, asset.path); + response.status(204).send(); + }); + } + + async getUploadStatus(auth: AuthDto, res: Response, id: string, { version }: GetUploadStatusDto): Promise { + return this.databaseRepository.withUuidLock(id, async () => { + const asset = await this.assetRepository.getCompletionMetadata(id, auth.user.id); + if (!asset) { + res.status(404).send('Asset not found'); + return; + } + + const offset = await this.getCurrentOffset(asset.path); + this.setCompleteHeader(res, version, asset.status !== AssetStatus.Partial); + res + .status(204) + .setHeader('Upload-Offset', offset.toString()) + .setHeader('Cache-Control', 'no-store') + .setHeader('Upload-Limit', 'min-size=0') + .send(); + }); + } + private pipe(req: Readable, res: Response, { id, path, size }: { id: string; path: string; size: number }) { const writeStream = this.storageRepository.createOrAppendWriteStream(path); writeStream.on('error', (error) => { @@ -228,48 +263,10 @@ export class AssetUploadService extends BaseService { return writeStream; } - cancelUpload(auth: AuthDto, assetId: string, response: Response): Promise { - 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'); - return; - } - if (asset.status !== AssetStatus.Partial) { - return this.sendAlreadyCompletedProblem(response); - } - await this.onCancel(assetId, asset.path); - response.status(204).send(); - }); - } - - async getUploadStatus(auth: AuthDto, res: Response, id: string, { version }: GetUploadStatusDto): Promise { - return this.databaseRepository.withUuidLock(id, async () => { - const asset = await this.assetRepository.getCompletionMetadata(id, auth.user.id); - if (!asset) { - res.status(404).send('Asset not found'); - return; - } - - const offset = await this.getCurrentOffset(asset.path); - this.setCompleteHeader(res, version, asset.status !== AssetStatus.Partial); - res - .status(204) - .setHeader('Upload-Offset', offset.toString()) - .setHeader('Cache-Control', 'no-store') - .setHeader('Upload-Limit', 'min-size=0') - .send(); - }); - } - - async getUploadOptions(response: Response): Promise { - response.status(204).setHeader('Upload-Limit', 'min-size=0').setHeader('Allow', 'POST, OPTIONS').send(); - } - private async onComplete({ id, path, fileModifiedAt }: { id: string; path: string; fileModifiedAt: Date }) { this.logger.debug('Completing upload for asset', id); const jobData = { name: JobName.AssetExtractMetadata, data: { id: id, source: 'upload' } } as const; - await withRetry(() => this.assetRepository.setCompleteWithSize(id)); + await withRetry(() => this.assetRepository.setComplete(id)); try { await withRetry(() => this.storageRepository.utimes(path, new Date(), fileModifiedAt)); } catch (error: any) { @@ -281,7 +278,7 @@ export class AssetUploadService extends BaseService { private async onCancel(assetId: string, path: string): Promise { this.logger.debug('Cancelling upload for asset', assetId); await withRetry(() => this.storageRepository.unlink(path)); - await withRetry(() => this.assetRepository.remove({ id: assetId })); + await withRetry(() => this.assetRepository.removeAndDecrementQuota(assetId)); } private sendInterimResponse({ socket }: Response, location: string, interopVersion: number): void { @@ -321,7 +318,7 @@ export class AssetUploadService extends BaseService { private sendChecksumMismatchResponse(res: Response, assetId: string, path: string): Promise { this.logger.warn(`Removing upload asset ${assetId} due to checksum mismatch`); - res.status(460).send('Checksum mismatch'); + res.status(460).send('File on server does not match provided checksum'); return this.onCancel(assetId, path); }