diff --git a/e2e/src/api/specs/asset-upload.e2e-spec.ts b/e2e/src/api/specs/asset-upload.e2e-spec.ts new file mode 100644 index 0000000000..540e83ca79 --- /dev/null +++ b/e2e/src/api/specs/asset-upload.e2e-spec.ts @@ -0,0 +1,434 @@ +import { LoginResponseDto } from '@immich/sdk'; +import { createHash, randomBytes } from 'node:crypto'; +import { createUserDto } from 'src/fixtures'; +import { app, baseUrl, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/upload (RUFH v9 compliance)', () => { + let admin: LoginResponseDto; + let user: 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')); + base64Metadata = Buffer.from( + JSON.stringify({ + filename: 'test-image.jpg', + deviceAssetId: 'rufh', + deviceId: 'test', + fileCreatedAt: new Date('2025-01-02T00:00:00Z').toISOString(), + fileModifiedAt: new Date('2025-01-01T00:00:00Z').toISOString(), + isFavorite: false, + }), + ).toString('base64'); + }); + + describe('Upload Creation (Section 4.2)', () => { + it('should create a complete upload with Upload-Complete: ?1', async () => { + const content = randomBytes(1024); + + const { status, headers } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .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(200); + expect(headers['upload-complete']).toBe('?1'); + expect(headers['upload-limit']).toEqual('min-size=0'); + }); + + it('should send 104 interim response for resumable upload support', async () => { + const content = randomBytes(1024); + let interimReceived = false; + let uploadResourceUri: string | undefined; + + const response = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?1') + .on('response', (res) => { + // Check for interim responses + res.on('data', (chunk: Buffer) => { + const data = chunk.toString(); + if (data.includes('HTTP/1.1 104')) { + interimReceived = true; + const locationMatch = data.match(/Location: (.*)/); + if (locationMatch) { + uploadResourceUri = locationMatch[1].trim(); + } + } + }); + }) + .send(content); + + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(300); + expect(interimReceived).toBe(true); + expect(uploadResourceUri).toBeDefined(); + }); + + it('should create an incomplete upload with Upload-Complete: ?0', async () => { + const partialContent = randomBytes(512); + + const { status, headers } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(partialContent).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Content-Length', partialContent.length.toString()) + .send(partialContent); + + expect(status).toBe(201); + expect(headers['upload-limit']).toEqual('min-size=0'); + expect(headers['location']).toMatch(/^\/api\/upload\/[a-zA-Z0-9\-]+$/); + }); + }); + + describe('Offset Retrieval (Section 4.3)', () => { + let uploadResource: string; + + beforeAll(async () => { + const content = randomBytes(512); + // Create an incomplete upload first + const { headers } = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?0') + .send(content); + + uploadResource = headers['location']; + }); + + it('should retrieve upload offset with HEAD request', async () => { + const { status, headers } = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`); + + expect(status).toBe(204); + expect(headers['upload-offset']).toBe('512'); + expect(headers['upload-complete']).toBe('?0'); + expect(headers['upload-limit']).toEqual('min-size=0'); + expect(headers['cache-control']).toBe('no-store'); + }); + + it('should return 400 for non-UUID upload resource', async () => { + const { status } = await request(app) + .head('/upload/nonexistent') + .set('Authorization', `Bearer ${user.accessToken}`); + + expect(status).toBe(400); + }); + + it('should return 404 for non-existent upload resource', async () => { + const { status } = await request(app) + .head('/upload/4feacf6f-830f-46c8-8140-2b3da67070c0') + .set('Authorization', `Bearer ${user.accessToken}`); + + expect(status).toBe(404); + }); + }); + + describe('Upload Append (Section 4.4)', () => { + let uploadResource: string; + let currentOffset: number; + + beforeAll(async () => { + // Create an incomplete upload + const initialContent = randomBytes(500); + const response = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(initialContent).digest('base64')}:`) + .set('Upload-Complete', '?0') + .send(initialContent); + + uploadResource = response.headers['location']; + currentOffset = 500; + }); + + it('should append data with correct offset', async () => { + const appendContent = randomBytes(500); + + const { status, headers } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Offset', currentOffset.toString()) + .set('Upload-Complete', '?0') + .set('Content-Type', 'application/partial-upload') + .send(appendContent); + + expect(status).toBe(204); + expect(headers['upload-complete']).toBe('?0'); + + // Verify new offset + const headResponse = await request(baseUrl) + .head(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`); + + expect(headResponse.headers['upload-offset']).toBe('1000'); + }); + + it('should reject append with mismatching offset (409 Conflict)', async () => { + const wrongOffset = 100; // Should be 1000 after previous test + + const { status, headers, body } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .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('1000'); // Correct offset + expect(body.type).toBe('https://iana.org/assignments/http-problem-types#mismatching-upload-offset'); + expect(body['expected-offset']).toBe(1000); + expect(body['provided-offset']).toBe(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}`); + + const offset = parseInt(headResponse.headers['upload-offset']); + const remainingContent = randomBytes(2000 - offset); + + const { status, headers } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Offset', offset.toString()) + .set('Upload-Complete', '?1') + .set('Content-Type', 'application/partial-upload') + .send(remainingContent); + + expect(status).toBe(200); + expect(headers['upload-complete']).toBe('?1'); + }); + + it('should reject append to completed upload', async () => { + const { status, body } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Offset', '2000') + .set('Upload-Complete', '?0') + .set('Content-Type', 'application/partial-upload') + .send(randomBytes(100)); + + expect(status).toBe(400); + expect(body.type).toBe('https://iana.org/assignments/http-problem-types#completed-upload'); + }); + }); + + 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('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?0') + .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}`); + + 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('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(totalContent).digest('base64')}:`) + .set('Upload-Complete', '?0') // Indicate incomplete + .send(firstPart); + + expect(initialResponse.status).toBe(201); + const uploadResource = initialResponse.headers['location']; + + // Check offset after interruption + const offsetResponse = await request(app).head(uploadResource).set('Authorization', `Bearer ${user.accessToken}`); + + 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-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('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(hash.digest('base64')).digest('base64')}:`) + .set('Upload-Complete', '?0') + .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-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}`); + + expect(offsetCheck.headers['upload-offset']).toBe('5000'); + + // Final resumption + response = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .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 inconsistent Upload-Length values', async () => { + const content = randomBytes(1000); + // Create upload with initial length + const initialResponse = await request(app) + .post('/upload') + .set('Authorization', `Bearer ${user.accessToken}`) + .set('X-Immich-Asset-Data', base64Metadata) + .set('Repr-Digest', `sha=:${createHash('sha1').update(content).digest('base64')}:`) + .set('Upload-Complete', '?0') + .set('Upload-Length', '5000') + .send(content); + + const uploadResource = initialResponse.headers['location']; + + // Try to append with different Upload-Length + const { status, body } = await request(baseUrl) + .patch(uploadResource) + .set('Authorization', `Bearer ${user.accessToken}`) + .set('Upload-Offset', '1000') + .set('Upload-Complete', '?0') + .set('Upload-Length', '6000') // Different from initial + .set('Content-Type', 'application/partial-upload') + .send(randomBytes(1000)); + + expect(status).toBe(400); + expect(body.type).toBe('https://iana.org/assignments/http-problem-types#inconsistent-upload-length'); + }); + + 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('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', content.length.toString()) + .send(content); + + expect(status).toBe(400); + expect(body.type).toBe('https://iana.org/assignments/http-problem-types#inconsistent-upload-length'); + }); + }); + + 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}`); + + expect(status).toBe(204); + expect(headers['upload-limit']).toBeDefined(); + + const limits = parseUploadLimit(headers['upload-limit']); + expect(limits).toHaveProperty('min-size'); + }); + }); +}); + +// Helper function to parse Upload-Limit header +function parseUploadLimit(headerValue: string): Record { + const limits: Record = {}; + if (!headerValue) return limits; + + // Parse structured field dictionary format + const pairs = headerValue.split(',').map((p) => p.trim()); + for (const pair of pairs) { + const [key, value] = pair.split('='); + if (key && value) { + limits[key] = parseInt(value, 10); + } + } + + return limits; +} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 85ec031736..092a9bbfc8 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -265,6 +265,11 @@ Class | Method | HTTP request | Description *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | *TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets | *TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | +*UploadApi* | [**cancelUpload**](doc//UploadApi.md#cancelupload) | **DELETE** /upload/{id} | +*UploadApi* | [**getUploadOptions**](doc//UploadApi.md#getuploadoptions) | **OPTIONS** /upload | +*UploadApi* | [**getUploadStatus**](doc//UploadApi.md#getuploadstatus) | **HEAD** /upload/{id} | +*UploadApi* | [**resumeUpload**](doc//UploadApi.md#resumeupload) | **PATCH** /upload/{id} | +*UploadApi* | [**startUpload**](doc//UploadApi.md#startupload) | **POST** /upload | *UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | *UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | *UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ab88670bcd..1d36fafc48 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -60,6 +60,7 @@ part 'api/system_metadata_api.dart'; part 'api/tags_api.dart'; part 'api/timeline_api.dart'; part 'api/trash_api.dart'; +part 'api/upload_api.dart'; part 'api/users_api.dart'; part 'api/users_admin_api.dart'; part 'api/view_api.dart'; diff --git a/mobile/openapi/lib/api/upload_api.dart b/mobile/openapi/lib/api/upload_api.dart new file mode 100644 index 0000000000..b7e04827f3 --- /dev/null +++ b/mobile/openapi/lib/api/upload_api.dart @@ -0,0 +1,308 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class UploadApi { + UploadApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// This endpoint requires the `asset.upload` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future cancelUploadWithHttpInfo(String id, { String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/upload/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future cancelUpload(String id, { String? key, String? slug, }) async { + final response = await cancelUploadWithHttpInfo(id, key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] key: + /// + /// * [String] slug: + Future getUploadOptionsWithHttpInfo({ String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/upload'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'OPTIONS', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Parameters: + /// + /// * [String] key: + /// + /// * [String] slug: + Future getUploadOptions({ String? key, String? slug, }) async { + final response = await getUploadOptionsWithHttpInfo( key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future getUploadStatusWithHttpInfo(String id, { String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/upload/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'HEAD', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future getUploadStatus(String id, { String? key, String? slug, }) async { + final response = await getUploadStatusWithHttpInfo(id, key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future resumeUploadWithHttpInfo(String id, { String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/upload/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'PATCH', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future resumeUpload(String id, { String? key, String? slug, }) async { + final response = await resumeUploadWithHttpInfo(id, key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] key: + /// + /// * [String] slug: + Future startUploadWithHttpInfo({ String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/upload'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint requires the `asset.upload` permission. + /// + /// Parameters: + /// + /// * [String] key: + /// + /// * [String] slug: + Future startUpload({ String? key, String? slug, }) async { + final response = await startUploadWithHttpInfo( key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d16e4c4e10..1505923ac4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9373,6 +9373,247 @@ "description": "This endpoint requires the `asset.delete` permission." } }, + "/upload": { + "options": { + "operationId": "getUploadOptions", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Upload" + ], + "x-immich-permission": "asset.upload", + "description": "This endpoint requires the `asset.upload` permission." + }, + "post": { + "operationId": "startUpload", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Upload" + ], + "x-immich-permission": "asset.upload", + "description": "This endpoint requires the `asset.upload` permission." + } + }, + "/upload/{id}": { + "delete": { + "operationId": "cancelUpload", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Upload" + ], + "x-immich-permission": "asset.upload", + "description": "This endpoint requires the `asset.upload` permission." + }, + "head": { + "operationId": "getUploadStatus", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Upload" + ], + "x-immich-permission": "asset.upload", + "description": "This endpoint requires the `asset.upload` permission." + }, + "patch": { + "operationId": "resumeUpload", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Upload" + ], + "x-immich-permission": "asset.upload", + "description": "This endpoint requires the `asset.upload` permission." + } + }, "/users": { "get": { "operationId": "searchUsers", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 435e10046a..cd0e5dc16b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4518,6 +4518,84 @@ export function restoreAssets({ bulkIdsDto }: { body: bulkIdsDto }))); } +/** + * This endpoint requires the `asset.upload` permission. + */ +export function getUploadOptions({ key, slug }: { + key?: string; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/upload${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts, + method: "OPTIONS" + })); +} +/** + * This endpoint requires the `asset.upload` permission. + */ +export function startUpload({ key, slug }: { + key?: string; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/upload${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts, + method: "POST" + })); +} +/** + * This endpoint requires the `asset.upload` permission. + */ +export function cancelUpload({ id, key, slug }: { + id: string; + key?: string; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts, + method: "DELETE" + })); +} +/** + * This endpoint requires the `asset.upload` permission. + */ +export function getUploadStatus({ id, key, slug }: { + id: string; + key?: string; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts, + method: "HEAD" + })); +} +/** + * This endpoint requires the `asset.upload` permission. + */ +export function resumeUpload({ id, key, slug }: { + id: string; + key?: string; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/upload/${encodeURIComponent(id)}${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts, + method: "PATCH" + })); +} /** * This endpoint requires the `user.read` permission. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4009157d1..ceb65ee04d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 22.18.13 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) byte-size: specifier: ^9.0.0 version: 9.0.1 @@ -115,10 +115,10 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest-fetch-mock: specifier: ^0.4.0 - version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) yaml: specifier: ^2.3.1 version: 2.8.1 @@ -616,7 +616,7 @@ importers: version: 13.15.3 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) eslint: specifier: ^9.14.0 version: 9.38.0(jiti@2.6.1) @@ -673,7 +673,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) web: dependencies: @@ -727,7 +727,7 @@ importers: version: 2.6.0 fabric: specifier: ^6.5.4 - version: 6.7.1 + version: 6.7.1(encoding@0.1.13) geo-coordinates-parser: specifier: ^1.7.4 version: 1.7.4 @@ -818,7 +818,7 @@ importers: version: 6.9.1 '@testing-library/svelte': specifier: ^5.2.8 - version: 5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) '@testing-library/user-event': specifier: ^14.5.2 version: 14.6.1(@testing-library/dom@10.4.0) @@ -842,7 +842,7 @@ importers: version: 1.5.6 '@vitest/coverage-v8': specifier: ^3.0.0 - version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) dotenv: specifier: ^17.0.0 version: 17.2.3 @@ -905,7 +905,7 @@ importers: version: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) packages: @@ -14654,22 +14654,6 @@ snapshots: dependencies: mapbox-gl: 1.13.3 - '@mapbox/node-pre-gyp@1.0.11': - dependencies: - detect-libc: 2.1.2 - https-proxy-agent: 5.0.1 - make-dir: 3.1.0 - node-fetch: 2.7.0 - nopt: 5.0.0 - npmlog: 5.0.1 - rimraf: 3.0.2 - semver: 7.7.3 - tar: 6.2.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': dependencies: detect-libc: 2.1.2 @@ -16168,13 +16152,13 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/svelte@5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@testing-library/svelte@5.2.8(svelte@5.41.3)(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@testing-library/dom': 10.4.0 svelte: 5.41.3 optionalDependencies: vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)': dependencies: @@ -16747,7 +16731,7 @@ snapshots: '@vercel/oidc@3.0.3': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16762,11 +16746,11 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -16781,7 +16765,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -17499,16 +17483,6 @@ snapshots: caniuse-lite@1.0.30001751: {} - canvas@2.11.2: - dependencies: - '@mapbox/node-pre-gyp': 1.0.11 - nan: 2.23.0 - simple-get: 3.1.1 - transitivePeerDependencies: - - encoding - - supports-color - optional: true - canvas@2.11.2(encoding@0.1.13): dependencies: '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) @@ -18905,10 +18879,10 @@ snapshots: extend@3.0.2: {} - fabric@6.7.1: + fabric@6.7.1(encoding@0.1.13): optionalDependencies: - canvas: 2.11.2 - jsdom: 20.0.3(canvas@2.11.2) + canvas: 2.11.2(encoding@0.1.13) + jsdom: 20.0.3(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - bufferutil - encoding @@ -20026,7 +20000,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@20.0.3(canvas@2.11.2): + jsdom@20.0.3(canvas@2.11.2(encoding@0.1.13)): dependencies: abab: 2.0.6 acorn: 8.15.0 @@ -20055,7 +20029,7 @@ snapshots: ws: 8.18.3 xml-name-validator: 4.0.0 optionalDependencies: - canvas: 2.11.2 + canvas: 2.11.2(encoding@0.1.13) transitivePeerDependencies: - bufferutil - supports-color @@ -20092,36 +20066,6 @@ snapshots: - utf-8-validate optional: true - jsdom@26.1.0(canvas@2.11.2): - dependencies: - cssstyle: 4.6.0 - data-urls: 5.0.0 - decimal.js: 10.6.0 - html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.22 - parse5: 7.3.0 - rrweb-cssom: 0.8.0 - saxes: 6.0.0 - symbol-tree: 3.2.4 - tough-cookie: 5.1.2 - w3c-xmlserializer: 5.0.0 - webidl-conversions: 7.0.0 - whatwg-encoding: 3.1.1 - whatwg-mimetype: 4.0.0 - whatwg-url: 14.2.0 - ws: 8.18.3 - xml-name-validator: 5.0.0 - optionalDependencies: - canvas: 2.11.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - optional: true - jsesc@3.0.2: {} jsesc@3.1.0: {} @@ -21272,11 +21216,6 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 - node-fetch@2.7.0: - dependencies: - whatwg-url: 5.0.0 - optional: true - node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -24303,9 +24242,9 @@ snapshots: optionalDependencies: vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): + vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)): dependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: @@ -24351,51 +24290,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.18.13)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): - dependencies: - '@types/chai': 5.2.2 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.2.0 - debug: 4.4.3 - expect-type: 1.2.1 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1) - why-is-node-running: 2.3.0 - optionalDependencies: - '@types/debug': 4.1.12 - '@types/node': 22.18.13 - happy-dom: 20.0.8 - jsdom: 26.1.0(canvas@2.11.2) - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.9.2)(happy-dom@20.0.8)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 @@ -24424,7 +24319,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 24.9.2 happy-dom: 20.0.8 - jsdom: 26.1.0(canvas@2.11.2) + jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13)) transitivePeerDependencies: - jiti - less diff --git a/server/Dockerfile b/server/Dockerfile index 460ad46f19..54077d80ce 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -55,7 +55,6 @@ ENV NODE_ENV=production \ NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_VISIBLE_DEVICES=all -COPY --from=builder /usr/bin/tusd /usr/bin/tusd COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli diff --git a/server/src/controllers/asset-upload.controller.ts b/server/src/controllers/asset-upload.controller.ts index 763bba510b..03bde04e82 100644 --- a/server/src/controllers/asset-upload.controller.ts +++ b/server/src/controllers/asset-upload.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Head, Param, Patch, Post, Req, Res } from '@nestjs/common'; +import { Controller, Delete, Head, Options, Param, Patch, Post, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -12,24 +12,36 @@ import { UUIDParamDto } from 'src/validation'; export class AssetUploadController { constructor(private service: AssetUploadService) {} - @Post('asset') + @Post() @Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) - handleInitialChunk(@Auth() auth: AuthDto, @Req() request: Request, @Res() response: Response): Promise { - return this.service.handleInitialChunk(auth, request, response); + startUpload(@Auth() auth: AuthDto, @Req() request: Request, @Res() response: Response): Promise { + return this.service.startUpload(auth, request, response); } - @Patch('asset/:id') + @Patch(':id') @Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) - handleRemainingChunks( + resumeUpload( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Req() request: Request, @Res() response: Response, ): Promise { - return this.service.handleRemainingChunks(auth, id, request, response); + return this.service.resumeUpload(auth, id, request, response); } - @Head('asset/:id') + @Delete(':id') + @Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) + cancelUpload(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Res() response: Response): Promise { + return this.service.cancelUpload(auth, id, response); + } + + @Options() + @Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) + getUploadOptions(@Res() response: Response): Promise { + return this.service.getUploadOptions(response); + } + + @Head(':id') @Authenticated({ sharedLink: true, permission: Permission.AssetUpload }) getUploadStatus( @Auth() auth: AuthDto, diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 0992ecdba1..9f74fefcbf 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -264,17 +264,30 @@ export class AssetRepository { .executeTakeFirst(); } - setComplete(assetId: string, ownerId: string, size: number) { + setCompleteWithSize(assetId: string, size: number) { return this.db - .with('exif', (qb) => qb.insertInto('asset_exif').values({ assetId, fileSizeInByte: size })) + .with('asset', (qb) => + qb + .updateTable('asset') + .set({ status: AssetStatus.Active }) + .where('asset.id', '=', assetId) + .where('asset.status', '=', sql.lit(AssetStatus.Partial)) + .returning(['asset.id', 'asset.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}` }) - .where('id', '=', ownerId), + .whereRef('user.id', '=', 'asset.ownerId'), ) - .updateTable('asset') - .set({ status: AssetStatus.Active }) + .selectNoFrom(sql`1`.as('dummy')) .execute(); } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index c5c473fe71..159ac8f44f 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -484,7 +484,7 @@ export class DatabaseRepository { } private async acquireUuidLock(uuid: string, connection: Kysely): Promise { - await sql`SELECT pg_advisory_lock(uuid_hash_extended(${uuid}), 0)`.execute(connection); + await sql`SELECT pg_advisory_lock(uuid_hash_extended(${uuid}, 0))`.execute(connection); } private async acquireTryLock(lock: DatabaseLock, connection: Kysely): Promise { @@ -499,6 +499,6 @@ export class DatabaseRepository { } private async releaseUuidLock(uuid: string, connection: Kysely): Promise { - await sql`SELECT pg_advisory_unlock(uuid_hash_extended(${uuid}), 0)`.execute(connection); + await sql`SELECT pg_advisory_unlock(uuid_hash_extended(${uuid}, 0))`.execute(connection); } } diff --git a/server/src/services/asset-upload.service.ts b/server/src/services/asset-upload.service.ts index 7fdb6e2271..d334f510d0 100644 --- a/server/src/services/asset-upload.service.ts +++ b/server/src/services/asset-upload.service.ts @@ -4,6 +4,7 @@ import { validateSync } from 'class-validator'; import { Request, Response } from 'express'; import { createHash } from 'node:crypto'; import { extname, join } from 'node:path'; +import { setTimeout } from 'node:timers/promises'; import { StorageCore } from 'src/cores/storage.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { UploadAssetDataDto } from 'src/dtos/upload.dto'; @@ -11,26 +12,33 @@ import { AssetStatus, AssetType, AssetVisibility, ImmichHeader, JobName, Storage import { BaseService } from 'src/services/base.service'; import { isAssetChecksumConstraint } from 'src/utils/database'; import { mimeTypes } from 'src/utils/mime-types'; -import { isInnerList, parseDictionary } from 'structured-headers'; +import { parseDictionary } from 'structured-headers'; @Injectable() export class AssetUploadService extends BaseService { - async handleInitialChunk(auth: AuthDto, request: Request, response: Response): Promise { + async startUpload(auth: AuthDto, request: Request, response: Response): Promise { const headers = request.headers; - const contentLength = this.getNumberOrThrow(headers, 'content-length'); - const isComplete = this.getIsCompleteOrThrow(headers); - const checksumHeader = this.getChecksumOrThrow(headers); + const contentLength = this.requireContentLength(headers); + const isComplete = this.requireUploadComplete(headers); + const metadata = this.requireAssetData(headers); + const checksumHeader = this.requireChecksum(headers); + const uploadLength = this.getUploadLength(headers); + + if (isComplete && uploadLength !== null && uploadLength !== contentLength) { + return this.sendInconsistentLengthProblem(response); + } - const metadata = this.getAssetDataOrThrow(headers); const assetId = this.cryptoRepository.randomUUID(); const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId); const extension = extname(metadata.filename); const path = join(folder, `${assetId}${extension}`); const type = mimeTypes.assetType(path); + if (type === AssetType.Other) { throw new BadRequestException(`${metadata.filename} is an unsupported file type`); } - this.requireQuota(auth, contentLength); + + this.validateQuota(auth, uploadLength ?? contentLength); try { await this.assetRepository.create({ @@ -58,20 +66,21 @@ export class AssetUploadService extends BaseService { throw new InternalServerErrorException('Error locating duplicate for checksum constraint'); } - if (duplicate.status === AssetStatus.Partial) { - response.status(201).setHeader('location', this.createLocation(headers, assetId)).send(); - } else { - response.status(400).contentType('application/problem+json').send({ - type: 'https://iana.org/assignments/http-problem-types#completed-upload', - title: 'upload is already completed', - }); + if (duplicate.status !== AssetStatus.Partial) { + return this.sendAlreadyCompletedProblem(response); } + const location = `/api/upload/${duplicate.id}`; + response.status(201).setHeader('Location', location).setHeader('Upload-Limit', 'min-size=0').send(); return; } this.logger.error(`Error creating upload asset record: ${error.message}`); response.status(500).send('Error creating upload asset record'); return; } + + const location = `/api/upload/${assetId}`; + // this.sendInterimResponse(response, location); + await this.storageRepository.mkdir(folder); let checksumBuffer: Buffer | undefined; const writeStream = this.storageRepository.createWriteStream(path); @@ -85,32 +94,34 @@ export class AssetUploadService extends BaseService { writeStream.on('error', (error) => { this.logger.error(`Failed to write chunk to ${path}: ${error.message}`); if (!response.headersSent) { - return response.status(500).setHeader('location', this.createLocation(headers, assetId)).send(); + response.status(500).setHeader('Location', location).send(); } }); writeStream.on('finish', () => { if (!isComplete) { - return response.status(201).setHeader('location', this.createLocation(headers, assetId)).send(); + return response.status(201).setHeader('Location', location).setHeader('Upload-Limit', 'min-size=0').send(); + } + this.logger.log(`Finished upload to ${path}`); + if (checksumHeader.compare(checksumBuffer!) !== 0) { + return this.sendChecksumMismatchResponse(response, assetId, path); } - this.logger.log(`Finished upload to ${path}`); - this.assertChecksum(checksumHeader, checksumBuffer!, path, assetId); - response.status(201).setHeader('Upload-Complete', '?1').send(); - void this.onCompletion({ - assetId, - ownerId: auth.user.id, - path, - size: contentLength, - fileModifiedAt: metadata.fileModifiedAt, - }); + response + .status(200) + .setHeader('Upload-Complete', '?1') + .setHeader('Location', location) + .setHeader('Upload-Limit', 'min-size=0') + .send(); + + return this.onComplete({ assetId, path, size: contentLength, fileModifiedAt: metadata.fileModifiedAt }); }); request.on('error', (error) => { this.logger.error(`Failed to read request body: ${error.message}`); writeStream.end(); if (!response.headersSent) { - return response.status(500).setHeader('location', this.createLocation(headers, assetId)).send(); + response.status(500).setHeader('Location', location).send(); } }); @@ -119,8 +130,8 @@ export class AssetUploadService extends BaseService { if (receivedLength + chunk.length > contentLength) { writeStream.destroy(); request.destroy(); - this.onPermanentFailure(assetId, path); response.status(400).send('Received more data than specified in content-length'); + return this.removeAsset(assetId, path); } receivedLength += chunk.length; if (!writeStream.write(chunk)) { @@ -130,18 +141,27 @@ export class AssetUploadService extends BaseService { }); request.on('end', () => { - if (receivedLength !== contentLength) { - this.logger.error(`Received ${receivedLength} bytes when expecting ${contentLength} for ${assetId}`); - writeStream.destroy(); - this.onPermanentFailure(assetId, path); + if (receivedLength === contentLength) { + return writeStream.end(); } + this.logger.error(`Received ${receivedLength} bytes when expecting ${contentLength} for ${assetId}`); + writeStream.destroy(); + this.removeAsset(assetId, path); }); } - async handleRemainingChunks(auth: AuthDto, assetId: string, request: Request, response: Response): Promise { + async resumeUpload(auth: AuthDto, assetId: string, request: Request, response: Response): Promise { const headers = request.headers; - const headerIsComplete = this.getIsCompleteOrThrow(headers); - const contentLength = this.getNumberOrThrow(headers, 'content-length'); + const isComplete = this.requireUploadComplete(headers); + const contentLength = this.requireContentLength(headers); + const providedOffset = this.getUploadOffset(headers); + const uploadLength = this.getUploadLength(headers); + + const contentType = headers['content-type']; + if (contentType !== 'application/partial-upload') { + throw new BadRequestException('Content-Type must be application/partial-upload for PATCH requests'); + } + await this.databaseRepository.withUuidLock(assetId, async () => { const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id); if (!asset) { @@ -149,36 +169,42 @@ export class AssetUploadService extends BaseService { return; } - const { path } = asset; if (asset.status !== AssetStatus.Partial) { - response.status(400).contentType('application/problem+json').send({ - type: 'https://iana.org/assignments/http-problem-types#completed-upload', - title: 'upload is already completed', - }); - return; + return this.sendAlreadyCompletedProblem(response); + } + if (providedOffset === null) { + throw new BadRequestException('Missing Upload-Offset header'); } - const providedOffset = this.getNumber(headers, 'upload-offset') ?? 0; + const { path } = asset; const expectedOffset = await this.getCurrentOffset(path); - if (expectedOffset !== providedOffset) { - response.status(409).contentType('application/problem+json').setHeader('upload-complete', '?0').send({ - type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset', - title: 'offset from request does not match offset of resource', - 'expected-offset': expectedOffset, - 'provided-offset': providedOffset, - }); + return this.sendOffsetMismatchProblem(response, expectedOffset, providedOffset); } const newLength = providedOffset + contentLength; - this.requireQuota(auth, newLength); - if (contentLength === 0) { - response.status(204).send(); + // If upload length is provided, validate we're not exceeding it + if (uploadLength !== null && newLength > uploadLength) { + response.status(400).send('Upload would exceed declared length'); + return; + } + + this.validateQuota(auth, newLength); + + // Empty PATCH without Upload-Complete + if (contentLength === 0 && !isComplete) { + response + .status(204) + .setHeader('Upload-Offset', expectedOffset.toString()) + .setHeader('Upload-Complete', '?0') + .send(); return; } const writeStream = this.storageRepository.createOrAppendWriteStream(path); + let receivedLength = 0; + writeStream.on('error', (error) => { this.logger.error(`Failed to write chunk to ${path}: ${error.message}`); if (!response.headersSent) { @@ -187,31 +213,37 @@ export class AssetUploadService extends BaseService { }); writeStream.on('finish', async () => { - if (headerIsComplete) { - this.logger.log(`Finished upload to ${path}`); - const checksum = await this.cryptoRepository.hashFile(path); - this.assertChecksum(asset.checksum, checksum, path, assetId); - response.status(201).setHeader('upload-complete', '?1').send(); - await this.onCompletion({ - assetId, - ownerId: auth.user.id, - path, - size: newLength, - fileModifiedAt: asset.fileModifiedAt, - }); - } else { - response.status(204).send(); + const currentOffset = await this.getCurrentOffset(path); + if (!isComplete) { + return response + .status(204) + .setHeader('Upload-Offset', currentOffset.toString()) + .setHeader('Upload-Complete', '?0') + .send(); } + + this.logger.log(`Finished upload to ${path}`); + const checksum = await this.cryptoRepository.hashFile(path); + if (asset.checksum.compare(checksum) !== 0) { + return this.sendChecksumMismatchResponse(response, assetId, path); + } + + response + .status(200) + .setHeader('Upload-Complete', '?1') + .setHeader('Upload-Offset', currentOffset.toString()) + .send(); + + await this.onComplete({ assetId, path, size: currentOffset, fileModifiedAt: asset.fileModifiedAt }); }); - let receivedLength = 0; request.on('data', (chunk: Buffer) => { if (receivedLength + chunk.length > contentLength) { this.logger.error(`Received more data than specified in content-length for upload to ${path}`); - writeStream.destroy(new Error('Received more data than specified in content-length')); + writeStream.destroy(); request.destroy(); - void this.onPermanentFailure(assetId, path); - return; + response.status(400).send('Received more data than specified in content-length'); + return this.removeAsset(assetId, path); } receivedLength += chunk.length; @@ -222,20 +254,17 @@ export class AssetUploadService extends BaseService { }); request.on('end', () => { - if (receivedLength < contentLength) { - this.logger.error(`Received less data than specified in content-length for upload to ${path}`); - writeStream.destroy(new Error('Received less data than specified in content-length')); - void this.onPermanentFailure(assetId, path); - return; + if (receivedLength === contentLength) { + return writeStream.end(); } - writeStream.end(); + this.logger.error(`Received ${receivedLength} bytes when expecting ${contentLength} for ${assetId}`); + writeStream.destroy(); + return this.removeAsset(assetId, path); }); }); } - getUploadStatus(auth: AuthDto, assetId: string, request: Request, response: Response) { - const headers = request.headers; - const interopVersion = this.getInteropVersion(headers); + async getUploadStatus(auth: AuthDto, assetId: string, request: Request, response: Response) { return this.databaseRepository.withUuidLock(assetId, async () => { const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id); if (!asset) { @@ -243,39 +272,141 @@ export class AssetUploadService extends BaseService { return; } - if (interopVersion !== null && interopVersion < 2) { - response.setHeader('upload-incomplete', asset.status === AssetStatus.Partial ? '?1' : '?0'); - } else { - response.setHeader('upload-complete', asset.status === AssetStatus.Partial ? '?0' : '?1'); - } + const offset = await this.getCurrentOffset(asset.path); + const isComplete = asset.status !== AssetStatus.Partial; response .status(204) - .setHeader('upload-offset', await this.getCurrentOffset(asset.path)) + .setHeader('Upload-Offset', offset.toString()) + .setHeader('Upload-Complete', isComplete ? '?1' : '?0') + .setHeader('Cache-Control', 'no-store') + .setHeader('Upload-Limit', 'min-size=0') .send(); }); } - private async onCompletion(data: { - assetId: string; - ownerId: string; - path: string; - size: number; - fileModifiedAt: Date; - }): Promise { - const { assetId, ownerId, path, size, fileModifiedAt } = data; + async getUploadOptions(response: Response): Promise { + response.status(204).setHeader('Upload-Limit', 'min-size=0').setHeader('Allow', 'POST, OPTIONS').send(); + } + + async cancelUpload(auth: AuthDto, assetId: string, response: Response): Promise { + 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.removeAsset(assetId, asset.path); + response.status(204).send(); + } + + private async onComplete(data: { assetId: string; path: string; size: number; fileModifiedAt: Date }): Promise { + const { assetId, path, size, fileModifiedAt } = data; const jobData = { name: JobName.AssetExtractMetadata, data: { id: assetId, source: 'upload' } } as const; - await this.withRetry(() => this.assetRepository.setComplete(assetId, ownerId, size), 2); - await this.withRetry(() => this.jobRepository.queue(jobData), 2); - await this.withRetry(() => this.storageRepository.utimes(path, new Date(), fileModifiedAt), 2); + await this.withRetry(() => this.assetRepository.setCompleteWithSize(assetId, size)); + await this.withRetry(() => this.jobRepository.queue(jobData)); + await this.withRetry(() => this.storageRepository.utimes(path, new Date(), fileModifiedAt)); } - private async onPermanentFailure(assetId: string, path: string): Promise { - await this.withRetry(() => this.storageRepository.unlink(path), 2); - await this.withRetry(() => this.assetRepository.remove({ id: assetId }), 2); + private async removeAsset(assetId: string, path: string): Promise { + await this.withRetry(() => this.storageRepository.unlink(path)); + await this.withRetry(() => this.assetRepository.remove({ id: assetId })); } - private async withRetry(operation: () => Promise, retries: number): Promise { + private sendInterimResponse(response: Response, location: string): void { + const socket = response.socket; + if (socket && !socket.destroyed) { + // Express doesn't understand interim responses, so write directly to socket + socket.write( + `HTTP/1.1 104 Upload Resumption Supported\r\n` + + `Location: ${location}\r\n` + + `Upload-Draft-Interop-Version: 8\r\n` + + `\r\n`, + ); + } + } + + private sendInconsistentLengthProblem(response: Response): void { + response.status(400).contentType('application/problem+json').send({ + type: `https://iana.org/assignments/http-problem-types#inconsistent-upload-length`, + title: 'inconsistent length values for upload', + }); + } + + private sendAlreadyCompletedProblem(response: Response): void { + response.status(400).contentType('application/problem+json').send({ + type: `https://iana.org/assignments/http-problem-types#completed-upload`, + title: 'upload is already completed', + }); + } + + private sendOffsetMismatchProblem(response: Response, expected: number, actual: number): void { + response + .status(409) + .contentType('application/problem+json') + .setHeader('Upload-Offset', expected.toString()) + .setHeader('Upload-Complete', '?0') + .send({ + type: 'https://iana.org/assignments/http-problem-types#mismatching-upload-offset', + title: 'offset from request does not match offset of resource', + 'expected-offset': expected, + 'provided-offset': actual, + }); + } + + private sendChecksumMismatchResponse(response: Response, assetId: string, path: string): Promise { + this.logger.warn(`Removing upload asset ${assetId} due to checksum mismatch`); + response.status(460).send('Checksum mismatch'); + return this.removeAsset(assetId, path); + } + + private requireUploadComplete(headers: Request['headers']): boolean { + const value = headers['upload-complete'] as string | undefined; + if (value === undefined) { + throw new BadRequestException('Missing Upload-Complete header'); + } + return value === '?1'; + } + + private getUploadOffset(headers: Request['headers']): number | null { + const value = headers['upload-offset'] as string | undefined; + if (value === undefined) { + return null; + } + const offset = parseInt(value, 10); + if (!isFinite(offset) || offset < 0) { + throw new BadRequestException('Invalid Upload-Offset header'); + } + return offset; + } + + private getUploadLength(headers: Request['headers']): number | null { + const value = headers['upload-length'] as string | undefined; + if (value === undefined) { + return null; + } + const length = parseInt(value, 10); + if (!isFinite(length) || length < 0) { + throw new BadRequestException('Invalid Upload-Length header'); + } + return length; + } + + private requireContentLength(headers: Request['headers']): number { + const value = headers['content-length'] as string | undefined; + if (value === undefined) { + throw new BadRequestException('Missing Content-Length header'); + } + const length = parseInt(value, 10); + if (!isFinite(length) || length < 0) { + throw new BadRequestException('Invalid Content-Length header'); + } + return length; + } + + private async withRetry(operation: () => Promise, retries: number = 2, delay: number = 100): Promise { let lastError: any; for (let attempt = 0; attempt <= retries; attempt++) { try { @@ -283,19 +414,14 @@ export class AssetUploadService extends BaseService { } catch (error: any) { lastError = error; } + if (attempt < retries) { + await setTimeout(delay); + } } throw lastError; } - private async tryUnlink(path: string): Promise { - try { - await this.storageRepository.unlink(path); - } catch { - this.logger.warn(`Failed to remove file at ${path}`); - } - } - - private requireQuota(auth: AuthDto, size: number) { + private validateQuota(auth: AuthDto, size: number) { if (auth.user.quotaSizeInBytes === null) { return; } @@ -317,28 +443,7 @@ export class AssetUploadService extends BaseService { } } - private getNumberOrThrow(headers: Request['headers'], header: string): number { - const value = this.getNumber(headers, header); - if (value === null) { - throw new BadRequestException(`Missing ${header} header`); - } - return value; - } - - private getNumber(headers: Request['headers'], header: string): number | null { - const value = headers[header] as string | undefined; - if (value === undefined) { - return null; - } - - const parsedValue = parseInt(value); - if (!isFinite(parsedValue) || parsedValue < 0) { - throw new BadRequestException(`Invalid ${header} header`); - } - return parsedValue; - } - - private getChecksumOrThrow(headers: Request['headers']): Buffer { + private requireChecksum(headers: Request['headers']): Buffer { const value = headers['repr-digest'] as string | undefined; if (value === undefined) { throw new BadRequestException(`Missing 'repr-digest' header`); @@ -349,9 +454,6 @@ export class AssetUploadService extends BaseService { throw new BadRequestException(`Missing 'sha' in 'repr-digest' header`); } - if (isInnerList(sha1Item)) { - throw new BadRequestException(`Invalid 'sha' in 'repr-digest' header`); - } const checksum = sha1Item[0]; if (!(checksum instanceof ArrayBuffer)) { throw new BadRequestException(`Invalid 'sha' in 'repr-digest' header`); @@ -360,22 +462,7 @@ export class AssetUploadService extends BaseService { return Buffer.from(checksum); } - private getIsCompleteOrThrow(headers: Request['headers']): boolean { - const isComplete = headers['upload-complete'] as string | undefined; - if (isComplete !== undefined) { - return isComplete === '?1'; - } - - // old drafts use this header - const isIncomplete = headers['upload-incomplete'] as string | undefined; - if (isIncomplete !== undefined) { - return isIncomplete === '?0'; - } - - throw new BadRequestException(`Missing 'upload-complete' header`); - } - - private getAssetDataOrThrow(headers: Request['headers']): UploadAssetDataDto { + private requireAssetData(headers: Request['headers']): UploadAssetDataDto { const value = headers[ImmichHeader.AssetData] as string | undefined; if (value === undefined) { throw new BadRequestException(`Missing ${ImmichHeader.AssetData} header`); @@ -387,64 +474,14 @@ export class AssetUploadService extends BaseService { } catch { throw new BadRequestException(`${ImmichHeader.AssetData} header is not valid base64-encoded JSON`); } + const dto = plainToInstance(UploadAssetDataDto, assetData); - const assetDataErrors = validateSync(dto, { whitelist: true }); - if (assetDataErrors.length > 0) { - const formatted = assetDataErrors.map((e) => (e.constraints ? Object.values(e.constraints).join(', ') : '')); + const errors = validateSync(dto, { whitelist: true }); + if (errors.length > 0) { + const formatted = errors.map((e) => (e.constraints ? Object.values(e.constraints).join(', ') : '')); throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header: ${formatted.join('; ')}`); } - if (!mimeTypes.isAsset(dto.filename)) { - throw new BadRequestException(`${dto.filename} is an unsupported file type`); - } return dto; } - - private getInteropVersion(headers: Request['headers']): number | null { - const value = headers['upload-draft-interop-version'] as string | undefined; - if (value === undefined) { - return null; - } - - const parsedValue = parseInt(value); - if (!isFinite(parsedValue) || parsedValue < 0) { - throw new BadRequestException(`Invalid Upload-Draft-Interop-Version header`); - } - return parsedValue; - } - - private createLocation(headers: Request['headers'], assetId: string): string { - const forwardedProto = headers['x-forwarded-proto'] ?? 'http'; - return `${forwardedProto}://${this.getForwardedHost(headers)}/api/upload/asset/${assetId}`; - } - - private assertChecksum(checksum1: Buffer, checksum2: Buffer, assetId: string, path: string): void { - if (checksum1.compare(checksum2) !== 0) { - this.logger.warn(`Checksum mismatch for upload to ${path}`); - void this.onPermanentFailure(assetId, path); - throw new BadRequestException('Checksum mismatch'); - } - } - - private getForwardedHost(headers: Request['headers']): string | undefined { - const forwardedHost = headers['x-forwarded-host']; - if (typeof forwardedHost === 'string') { - return forwardedHost; - } - - const forwarded = headers['forwarded'] as string | undefined; - if (forwarded) { - const parts = parseDictionary(forwarded); - const hostItem = parts.get('host'); - if (hostItem && !isInnerList(hostItem)) { - const item = hostItem[0]; - if (typeof item === 'string') { - return item; - } - } - } - - const { host, port } = this.configRepository.getEnv(); - return `${host ?? 'localhost'}:${port}`; - } } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0cc3788f1a..b0ed42c5dc 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -99,7 +99,7 @@ export const getKyselyConfig = ( }), }), log(event) { - if (event.level === 'error') { + if (event.level === 'error' && (event.error as PostgresError).constraint_name !== ASSET_CHECKSUM_CONSTRAINT) { console.error('Query failed :', { durationMs: event.queryDurationMillis, error: event.error,