mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
interop v8 compliance
This commit is contained in:
parent
7561c5e1c4
commit
f80326872e
13 changed files with 1373 additions and 350 deletions
434
e2e/src/api/specs/asset-upload.e2e-spec.ts
Normal file
434
e2e/src/api/specs/asset-upload.e2e-spec.ts
Normal file
|
|
@ -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<string, number> {
|
||||||
|
const limits: Record<string, number> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
5
mobile/openapi/README.md
generated
5
mobile/openapi/README.md
generated
|
|
@ -265,6 +265,11 @@ Class | Method | HTTP request | Description
|
||||||
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty |
|
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty |
|
||||||
*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets |
|
*TrashApi* | [**restoreAssets**](doc//TrashApi.md#restoreassets) | **POST** /trash/restore/assets |
|
||||||
*TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore |
|
*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* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image |
|
||||||
*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image |
|
*UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image |
|
||||||
*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license |
|
*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license |
|
||||||
|
|
|
||||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
|
@ -60,6 +60,7 @@ part 'api/system_metadata_api.dart';
|
||||||
part 'api/tags_api.dart';
|
part 'api/tags_api.dart';
|
||||||
part 'api/timeline_api.dart';
|
part 'api/timeline_api.dart';
|
||||||
part 'api/trash_api.dart';
|
part 'api/trash_api.dart';
|
||||||
|
part 'api/upload_api.dart';
|
||||||
part 'api/users_api.dart';
|
part 'api/users_api.dart';
|
||||||
part 'api/users_admin_api.dart';
|
part 'api/users_admin_api.dart';
|
||||||
part 'api/view_api.dart';
|
part 'api/view_api.dart';
|
||||||
|
|
|
||||||
308
mobile/openapi/lib/api/upload_api.dart
generated
Normal file
308
mobile/openapi/lib/api/upload_api.dart
generated
Normal file
|
|
@ -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<Response> 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 = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
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<void> 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<Response> getUploadOptionsWithHttpInfo({ String? key, String? slug, }) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/upload';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
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<void> 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<Response> 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 = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
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<void> 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<Response> 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 = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
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<void> 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<Response> startUploadWithHttpInfo({ String? key, String? slug, }) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/upload';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
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<void> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9373,6 +9373,247 @@
|
||||||
"description": "This endpoint requires the `asset.delete` permission."
|
"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": {
|
"/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "searchUsers",
|
"operationId": "searchUsers",
|
||||||
|
|
|
||||||
|
|
@ -4518,6 +4518,84 @@ export function restoreAssets({ bulkIdsDto }: {
|
||||||
body: 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.
|
* This endpoint requires the `user.read` permission.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
153
pnpm-lock.yaml
generated
153
pnpm-lock.yaml
generated
|
|
@ -67,7 +67,7 @@ importers:
|
||||||
version: 22.18.13
|
version: 22.18.13
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
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:
|
byte-size:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.0.1
|
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))
|
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:
|
vitest:
|
||||||
specifier: ^3.0.0
|
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:
|
vitest-fetch-mock:
|
||||||
specifier: ^0.4.0
|
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:
|
yaml:
|
||||||
specifier: ^2.3.1
|
specifier: ^2.3.1
|
||||||
version: 2.8.1
|
version: 2.8.1
|
||||||
|
|
@ -616,7 +616,7 @@ importers:
|
||||||
version: 13.15.3
|
version: 13.15.3
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
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:
|
eslint:
|
||||||
specifier: ^9.14.0
|
specifier: ^9.14.0
|
||||||
version: 9.38.0(jiti@2.6.1)
|
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))
|
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:
|
vitest:
|
||||||
specifier: ^3.0.0
|
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:
|
web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -727,7 +727,7 @@ importers:
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
fabric:
|
fabric:
|
||||||
specifier: ^6.5.4
|
specifier: ^6.5.4
|
||||||
version: 6.7.1
|
version: 6.7.1(encoding@0.1.13)
|
||||||
geo-coordinates-parser:
|
geo-coordinates-parser:
|
||||||
specifier: ^1.7.4
|
specifier: ^1.7.4
|
||||||
version: 1.7.4
|
version: 1.7.4
|
||||||
|
|
@ -818,7 +818,7 @@ importers:
|
||||||
version: 6.9.1
|
version: 6.9.1
|
||||||
'@testing-library/svelte':
|
'@testing-library/svelte':
|
||||||
specifier: ^5.2.8
|
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':
|
'@testing-library/user-event':
|
||||||
specifier: ^14.5.2
|
specifier: ^14.5.2
|
||||||
version: 14.6.1(@testing-library/dom@10.4.0)
|
version: 14.6.1(@testing-library/dom@10.4.0)
|
||||||
|
|
@ -842,7 +842,7 @@ importers:
|
||||||
version: 1.5.6
|
version: 1.5.6
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^3.0.0
|
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:
|
dotenv:
|
||||||
specifier: ^17.0.0
|
specifier: ^17.0.0
|
||||||
version: 17.2.3
|
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)
|
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:
|
vitest:
|
||||||
specifier: ^3.0.0
|
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:
|
packages:
|
||||||
|
|
||||||
|
|
@ -14654,22 +14654,6 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
mapbox-gl: 1.13.3
|
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)':
|
'@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)':
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
|
|
@ -16168,13 +16152,13 @@ snapshots:
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
redent: 3.0.0
|
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:
|
dependencies:
|
||||||
'@testing-library/dom': 10.4.0
|
'@testing-library/dom': 10.4.0
|
||||||
svelte: 5.41.3
|
svelte: 5.41.3
|
||||||
optionalDependencies:
|
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)
|
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)':
|
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -16747,7 +16731,7 @@ snapshots:
|
||||||
|
|
||||||
'@vercel/oidc@3.0.3': {}
|
'@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:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
|
|
@ -16762,11 +16746,11 @@ snapshots:
|
||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
test-exclude: 7.0.1
|
test-exclude: 7.0.1
|
||||||
tinyrainbow: 2.0.0
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@ampproject/remapping': 2.3.0
|
'@ampproject/remapping': 2.3.0
|
||||||
'@bcoe/v8-coverage': 1.0.2
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
|
|
@ -16781,7 +16765,7 @@ snapshots:
|
||||||
std-env: 3.10.0
|
std-env: 3.10.0
|
||||||
test-exclude: 7.0.1
|
test-exclude: 7.0.1
|
||||||
tinyrainbow: 2.0.0
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|
@ -17499,16 +17483,6 @@ snapshots:
|
||||||
|
|
||||||
caniuse-lite@1.0.30001751: {}
|
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):
|
canvas@2.11.2(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
|
'@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13)
|
||||||
|
|
@ -18905,10 +18879,10 @@ snapshots:
|
||||||
|
|
||||||
extend@3.0.2: {}
|
extend@3.0.2: {}
|
||||||
|
|
||||||
fabric@6.7.1:
|
fabric@6.7.1(encoding@0.1.13):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
canvas: 2.11.2
|
canvas: 2.11.2(encoding@0.1.13)
|
||||||
jsdom: 20.0.3(canvas@2.11.2)
|
jsdom: 20.0.3(canvas@2.11.2(encoding@0.1.13))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- encoding
|
- encoding
|
||||||
|
|
@ -20026,7 +20000,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
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:
|
dependencies:
|
||||||
abab: 2.0.6
|
abab: 2.0.6
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
|
|
@ -20055,7 +20029,7 @@ snapshots:
|
||||||
ws: 8.18.3
|
ws: 8.18.3
|
||||||
xml-name-validator: 4.0.0
|
xml-name-validator: 4.0.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
canvas: 2.11.2
|
canvas: 2.11.2(encoding@0.1.13)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
@ -20092,36 +20066,6 @@ snapshots:
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
optional: true
|
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.0.2: {}
|
||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
|
|
@ -21272,11 +21216,6 @@ snapshots:
|
||||||
emojilib: 2.4.0
|
emojilib: 2.4.0
|
||||||
skin-tone: 2.0.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):
|
node-fetch@2.7.0(encoding@0.1.13):
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
whatwg-url: 5.0.0
|
||||||
|
|
@ -24303,9 +24242,9 @@ snapshots:
|
||||||
optionalDependencies:
|
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)
|
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:
|
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):
|
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:
|
dependencies:
|
||||||
|
|
@ -24351,51 +24290,7 @@ snapshots:
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- 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):
|
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
|
|
||||||
'@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):
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.2
|
'@types/chai': 5.2.2
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
|
|
@ -24424,7 +24319,7 @@ snapshots:
|
||||||
'@types/debug': 4.1.12
|
'@types/debug': 4.1.12
|
||||||
'@types/node': 24.9.2
|
'@types/node': 24.9.2
|
||||||
happy-dom: 20.0.8
|
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:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ ENV NODE_ENV=production \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||||
NVIDIA_VISIBLE_DEVICES=all
|
NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
|
||||||
COPY --from=builder /usr/bin/tusd /usr/bin/tusd
|
|
||||||
COPY --from=server /output/server-pruned ./server
|
COPY --from=server /output/server-pruned ./server
|
||||||
COPY --from=web /usr/src/app/web/build /build/www
|
COPY --from=web /usr/src/app/web/build /build/www
|
||||||
COPY --from=cli /output/cli-pruned ./cli
|
COPY --from=cli /output/cli-pruned ./cli
|
||||||
|
|
|
||||||
|
|
@ -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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
|
@ -12,24 +12,36 @@ import { UUIDParamDto } from 'src/validation';
|
||||||
export class AssetUploadController {
|
export class AssetUploadController {
|
||||||
constructor(private service: AssetUploadService) {}
|
constructor(private service: AssetUploadService) {}
|
||||||
|
|
||||||
@Post('asset')
|
@Post()
|
||||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||||
handleInitialChunk(@Auth() auth: AuthDto, @Req() request: Request, @Res() response: Response): Promise<void> {
|
startUpload(@Auth() auth: AuthDto, @Req() request: Request, @Res() response: Response): Promise<void> {
|
||||||
return this.service.handleInitialChunk(auth, request, response);
|
return this.service.startUpload(auth, request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('asset/:id')
|
@Patch(':id')
|
||||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||||
handleRemainingChunks(
|
resumeUpload(
|
||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
@Param() { id }: UUIDParamDto,
|
@Param() { id }: UUIDParamDto,
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res() response: Response,
|
@Res() response: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
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<void> {
|
||||||
|
return this.service.cancelUpload(auth, id, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Options()
|
||||||
|
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||||
|
getUploadOptions(@Res() response: Response): Promise<void> {
|
||||||
|
return this.service.getUploadOptions(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Head(':id')
|
||||||
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
@Authenticated({ sharedLink: true, permission: Permission.AssetUpload })
|
||||||
getUploadStatus(
|
getUploadStatus(
|
||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
|
|
|
||||||
|
|
@ -264,17 +264,30 @@ export class AssetRepository {
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
setComplete(assetId: string, ownerId: string, size: number) {
|
setCompleteWithSize(assetId: string, size: number) {
|
||||||
return this.db
|
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) =>
|
.with('user', (qb) =>
|
||||||
qb
|
qb
|
||||||
.updateTable('user')
|
.updateTable('user')
|
||||||
|
.from('asset')
|
||||||
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` })
|
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` })
|
||||||
.where('id', '=', ownerId),
|
.whereRef('user.id', '=', 'asset.ownerId'),
|
||||||
)
|
)
|
||||||
.updateTable('asset')
|
.selectNoFrom(sql`1`.as('dummy'))
|
||||||
.set({ status: AssetStatus.Active })
|
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -484,7 +484,7 @@ export class DatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async acquireUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
|
private async acquireUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
|
||||||
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<DB>): Promise<boolean> {
|
private async acquireTryLock(lock: DatabaseLock, connection: Kysely<DB>): Promise<boolean> {
|
||||||
|
|
@ -499,6 +499,6 @@ export class DatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async releaseUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
|
private async releaseUuidLock(uuid: string, connection: Kysely<DB>): Promise<void> {
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { validateSync } from 'class-validator';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { extname, join } from 'node:path';
|
import { extname, join } from 'node:path';
|
||||||
|
import { setTimeout } from 'node:timers/promises';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { UploadAssetDataDto } from 'src/dtos/upload.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 { BaseService } from 'src/services/base.service';
|
||||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { isInnerList, parseDictionary } from 'structured-headers';
|
import { parseDictionary } from 'structured-headers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetUploadService extends BaseService {
|
export class AssetUploadService extends BaseService {
|
||||||
async handleInitialChunk(auth: AuthDto, request: Request, response: Response): Promise<void> {
|
async startUpload(auth: AuthDto, request: Request, response: Response): Promise<void> {
|
||||||
const headers = request.headers;
|
const headers = request.headers;
|
||||||
const contentLength = this.getNumberOrThrow(headers, 'content-length');
|
const contentLength = this.requireContentLength(headers);
|
||||||
const isComplete = this.getIsCompleteOrThrow(headers);
|
const isComplete = this.requireUploadComplete(headers);
|
||||||
const checksumHeader = this.getChecksumOrThrow(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 assetId = this.cryptoRepository.randomUUID();
|
||||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
|
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, assetId);
|
||||||
const extension = extname(metadata.filename);
|
const extension = extname(metadata.filename);
|
||||||
const path = join(folder, `${assetId}${extension}`);
|
const path = join(folder, `${assetId}${extension}`);
|
||||||
const type = mimeTypes.assetType(path);
|
const type = mimeTypes.assetType(path);
|
||||||
|
|
||||||
if (type === AssetType.Other) {
|
if (type === AssetType.Other) {
|
||||||
throw new BadRequestException(`${metadata.filename} is an unsupported file type`);
|
throw new BadRequestException(`${metadata.filename} is an unsupported file type`);
|
||||||
}
|
}
|
||||||
this.requireQuota(auth, contentLength);
|
|
||||||
|
this.validateQuota(auth, uploadLength ?? contentLength);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.assetRepository.create({
|
await this.assetRepository.create({
|
||||||
|
|
@ -58,20 +66,21 @@ export class AssetUploadService extends BaseService {
|
||||||
throw new InternalServerErrorException('Error locating duplicate for checksum constraint');
|
throw new InternalServerErrorException('Error locating duplicate for checksum constraint');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (duplicate.status === AssetStatus.Partial) {
|
if (duplicate.status !== AssetStatus.Partial) {
|
||||||
response.status(201).setHeader('location', this.createLocation(headers, assetId)).send();
|
return this.sendAlreadyCompletedProblem(response);
|
||||||
} else {
|
|
||||||
response.status(400).contentType('application/problem+json').send({
|
|
||||||
type: 'https://iana.org/assignments/http-problem-types#completed-upload',
|
|
||||||
title: 'upload is already completed',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
const location = `/api/upload/${duplicate.id}`;
|
||||||
|
response.status(201).setHeader('Location', location).setHeader('Upload-Limit', 'min-size=0').send();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.logger.error(`Error creating upload asset record: ${error.message}`);
|
this.logger.error(`Error creating upload asset record: ${error.message}`);
|
||||||
response.status(500).send('Error creating upload asset record');
|
response.status(500).send('Error creating upload asset record');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const location = `/api/upload/${assetId}`;
|
||||||
|
// this.sendInterimResponse(response, location);
|
||||||
|
|
||||||
await this.storageRepository.mkdir(folder);
|
await this.storageRepository.mkdir(folder);
|
||||||
let checksumBuffer: Buffer | undefined;
|
let checksumBuffer: Buffer | undefined;
|
||||||
const writeStream = this.storageRepository.createWriteStream(path);
|
const writeStream = this.storageRepository.createWriteStream(path);
|
||||||
|
|
@ -85,32 +94,34 @@ export class AssetUploadService extends BaseService {
|
||||||
writeStream.on('error', (error) => {
|
writeStream.on('error', (error) => {
|
||||||
this.logger.error(`Failed to write chunk to ${path}: ${error.message}`);
|
this.logger.error(`Failed to write chunk to ${path}: ${error.message}`);
|
||||||
if (!response.headersSent) {
|
if (!response.headersSent) {
|
||||||
return response.status(500).setHeader('location', this.createLocation(headers, assetId)).send();
|
response.status(500).setHeader('Location', location).send();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
writeStream.on('finish', () => {
|
writeStream.on('finish', () => {
|
||||||
if (!isComplete) {
|
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}`);
|
response
|
||||||
this.assertChecksum(checksumHeader, checksumBuffer!, path, assetId);
|
.status(200)
|
||||||
response.status(201).setHeader('Upload-Complete', '?1').send();
|
.setHeader('Upload-Complete', '?1')
|
||||||
void this.onCompletion({
|
.setHeader('Location', location)
|
||||||
assetId,
|
.setHeader('Upload-Limit', 'min-size=0')
|
||||||
ownerId: auth.user.id,
|
.send();
|
||||||
path,
|
|
||||||
size: contentLength,
|
return this.onComplete({ assetId, path, size: contentLength, fileModifiedAt: metadata.fileModifiedAt });
|
||||||
fileModifiedAt: metadata.fileModifiedAt,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
request.on('error', (error) => {
|
request.on('error', (error) => {
|
||||||
this.logger.error(`Failed to read request body: ${error.message}`);
|
this.logger.error(`Failed to read request body: ${error.message}`);
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
if (!response.headersSent) {
|
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) {
|
if (receivedLength + chunk.length > contentLength) {
|
||||||
writeStream.destroy();
|
writeStream.destroy();
|
||||||
request.destroy();
|
request.destroy();
|
||||||
this.onPermanentFailure(assetId, path);
|
|
||||||
response.status(400).send('Received more data than specified in content-length');
|
response.status(400).send('Received more data than specified in content-length');
|
||||||
|
return this.removeAsset(assetId, path);
|
||||||
}
|
}
|
||||||
receivedLength += chunk.length;
|
receivedLength += chunk.length;
|
||||||
if (!writeStream.write(chunk)) {
|
if (!writeStream.write(chunk)) {
|
||||||
|
|
@ -130,18 +141,27 @@ export class AssetUploadService extends BaseService {
|
||||||
});
|
});
|
||||||
|
|
||||||
request.on('end', () => {
|
request.on('end', () => {
|
||||||
if (receivedLength !== contentLength) {
|
if (receivedLength === contentLength) {
|
||||||
this.logger.error(`Received ${receivedLength} bytes when expecting ${contentLength} for ${assetId}`);
|
return writeStream.end();
|
||||||
writeStream.destroy();
|
|
||||||
this.onPermanentFailure(assetId, path);
|
|
||||||
}
|
}
|
||||||
|
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<void> {
|
async resumeUpload(auth: AuthDto, assetId: string, request: Request, response: Response): Promise<void> {
|
||||||
const headers = request.headers;
|
const headers = request.headers;
|
||||||
const headerIsComplete = this.getIsCompleteOrThrow(headers);
|
const isComplete = this.requireUploadComplete(headers);
|
||||||
const contentLength = this.getNumberOrThrow(headers, 'content-length');
|
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 () => {
|
await this.databaseRepository.withUuidLock(assetId, async () => {
|
||||||
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
|
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
|
|
@ -149,36 +169,42 @@ export class AssetUploadService extends BaseService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path } = asset;
|
|
||||||
if (asset.status !== AssetStatus.Partial) {
|
if (asset.status !== AssetStatus.Partial) {
|
||||||
response.status(400).contentType('application/problem+json').send({
|
return this.sendAlreadyCompletedProblem(response);
|
||||||
type: 'https://iana.org/assignments/http-problem-types#completed-upload',
|
}
|
||||||
title: 'upload is already completed',
|
if (providedOffset === null) {
|
||||||
});
|
throw new BadRequestException('Missing Upload-Offset header');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const providedOffset = this.getNumber(headers, 'upload-offset') ?? 0;
|
const { path } = asset;
|
||||||
const expectedOffset = await this.getCurrentOffset(path);
|
const expectedOffset = await this.getCurrentOffset(path);
|
||||||
|
|
||||||
if (expectedOffset !== providedOffset) {
|
if (expectedOffset !== providedOffset) {
|
||||||
response.status(409).contentType('application/problem+json').setHeader('upload-complete', '?0').send({
|
return this.sendOffsetMismatchProblem(response, expectedOffset, providedOffset);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLength = providedOffset + contentLength;
|
const newLength = providedOffset + contentLength;
|
||||||
this.requireQuota(auth, newLength);
|
|
||||||
|
|
||||||
if (contentLength === 0) {
|
// If upload length is provided, validate we're not exceeding it
|
||||||
response.status(204).send();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeStream = this.storageRepository.createOrAppendWriteStream(path);
|
const writeStream = this.storageRepository.createOrAppendWriteStream(path);
|
||||||
|
let receivedLength = 0;
|
||||||
|
|
||||||
writeStream.on('error', (error) => {
|
writeStream.on('error', (error) => {
|
||||||
this.logger.error(`Failed to write chunk to ${path}: ${error.message}`);
|
this.logger.error(`Failed to write chunk to ${path}: ${error.message}`);
|
||||||
if (!response.headersSent) {
|
if (!response.headersSent) {
|
||||||
|
|
@ -187,31 +213,37 @@ export class AssetUploadService extends BaseService {
|
||||||
});
|
});
|
||||||
|
|
||||||
writeStream.on('finish', async () => {
|
writeStream.on('finish', async () => {
|
||||||
if (headerIsComplete) {
|
const currentOffset = await this.getCurrentOffset(path);
|
||||||
this.logger.log(`Finished upload to ${path}`);
|
if (!isComplete) {
|
||||||
const checksum = await this.cryptoRepository.hashFile(path);
|
return response
|
||||||
this.assertChecksum(asset.checksum, checksum, path, assetId);
|
.status(204)
|
||||||
response.status(201).setHeader('upload-complete', '?1').send();
|
.setHeader('Upload-Offset', currentOffset.toString())
|
||||||
await this.onCompletion({
|
.setHeader('Upload-Complete', '?0')
|
||||||
assetId,
|
.send();
|
||||||
ownerId: auth.user.id,
|
|
||||||
path,
|
|
||||||
size: newLength,
|
|
||||||
fileModifiedAt: asset.fileModifiedAt,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
response.status(204).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) => {
|
request.on('data', (chunk: Buffer) => {
|
||||||
if (receivedLength + chunk.length > contentLength) {
|
if (receivedLength + chunk.length > contentLength) {
|
||||||
this.logger.error(`Received more data than specified in content-length for upload to ${path}`);
|
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();
|
request.destroy();
|
||||||
void this.onPermanentFailure(assetId, path);
|
response.status(400).send('Received more data than specified in content-length');
|
||||||
return;
|
return this.removeAsset(assetId, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
receivedLength += chunk.length;
|
receivedLength += chunk.length;
|
||||||
|
|
@ -222,20 +254,17 @@ export class AssetUploadService extends BaseService {
|
||||||
});
|
});
|
||||||
|
|
||||||
request.on('end', () => {
|
request.on('end', () => {
|
||||||
if (receivedLength < contentLength) {
|
if (receivedLength === contentLength) {
|
||||||
this.logger.error(`Received less data than specified in content-length for upload to ${path}`);
|
return writeStream.end();
|
||||||
writeStream.destroy(new Error('Received less data than specified in content-length'));
|
|
||||||
void this.onPermanentFailure(assetId, path);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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) {
|
async getUploadStatus(auth: AuthDto, assetId: string, request: Request, response: Response) {
|
||||||
const headers = request.headers;
|
|
||||||
const interopVersion = this.getInteropVersion(headers);
|
|
||||||
return this.databaseRepository.withUuidLock(assetId, async () => {
|
return this.databaseRepository.withUuidLock(assetId, async () => {
|
||||||
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
|
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
|
|
@ -243,39 +272,141 @@ export class AssetUploadService extends BaseService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interopVersion !== null && interopVersion < 2) {
|
const offset = await this.getCurrentOffset(asset.path);
|
||||||
response.setHeader('upload-incomplete', asset.status === AssetStatus.Partial ? '?1' : '?0');
|
const isComplete = asset.status !== AssetStatus.Partial;
|
||||||
} else {
|
|
||||||
response.setHeader('upload-complete', asset.status === AssetStatus.Partial ? '?0' : '?1');
|
|
||||||
}
|
|
||||||
|
|
||||||
response
|
response
|
||||||
.status(204)
|
.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();
|
.send();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onCompletion(data: {
|
async getUploadOptions(response: Response): Promise<void> {
|
||||||
assetId: string;
|
response.status(204).setHeader('Upload-Limit', 'min-size=0').setHeader('Allow', 'POST, OPTIONS').send();
|
||||||
ownerId: string;
|
}
|
||||||
path: string;
|
|
||||||
size: number;
|
async cancelUpload(auth: AuthDto, assetId: string, response: Response): Promise<void> {
|
||||||
fileModifiedAt: Date;
|
const asset = await this.assetRepository.getCompletionMetadata(assetId, auth.user.id);
|
||||||
}): Promise<void> {
|
if (!asset) {
|
||||||
const { assetId, ownerId, path, size, fileModifiedAt } = data;
|
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<void> {
|
||||||
|
const { assetId, path, size, fileModifiedAt } = data;
|
||||||
const jobData = { name: JobName.AssetExtractMetadata, data: { id: assetId, source: 'upload' } } as const;
|
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.assetRepository.setCompleteWithSize(assetId, size));
|
||||||
await this.withRetry(() => this.jobRepository.queue(jobData), 2);
|
await this.withRetry(() => this.jobRepository.queue(jobData));
|
||||||
await this.withRetry(() => this.storageRepository.utimes(path, new Date(), fileModifiedAt), 2);
|
await this.withRetry(() => this.storageRepository.utimes(path, new Date(), fileModifiedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onPermanentFailure(assetId: string, path: string): Promise<void> {
|
private async removeAsset(assetId: string, path: string): Promise<void> {
|
||||||
await this.withRetry(() => this.storageRepository.unlink(path), 2);
|
await this.withRetry(() => this.storageRepository.unlink(path));
|
||||||
await this.withRetry(() => this.assetRepository.remove({ id: assetId }), 2);
|
await this.withRetry(() => this.assetRepository.remove({ id: assetId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async withRetry<T>(operation: () => Promise<T>, retries: number): Promise<T> {
|
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<void> {
|
||||||
|
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<T>(operation: () => Promise<T>, retries: number = 2, delay: number = 100): Promise<T> {
|
||||||
let lastError: any;
|
let lastError: any;
|
||||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -283,19 +414,14 @@ export class AssetUploadService extends BaseService {
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
}
|
}
|
||||||
|
if (attempt < retries) {
|
||||||
|
await setTimeout(delay);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryUnlink(path: string): Promise<void> {
|
private validateQuota(auth: AuthDto, size: number) {
|
||||||
try {
|
|
||||||
await this.storageRepository.unlink(path);
|
|
||||||
} catch {
|
|
||||||
this.logger.warn(`Failed to remove file at ${path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireQuota(auth: AuthDto, size: number) {
|
|
||||||
if (auth.user.quotaSizeInBytes === null) {
|
if (auth.user.quotaSizeInBytes === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -317,28 +443,7 @@ export class AssetUploadService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNumberOrThrow(headers: Request['headers'], header: string): number {
|
private requireChecksum(headers: Request['headers']): Buffer {
|
||||||
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 {
|
|
||||||
const value = headers['repr-digest'] as string | undefined;
|
const value = headers['repr-digest'] as string | undefined;
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
throw new BadRequestException(`Missing 'repr-digest' header`);
|
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`);
|
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];
|
const checksum = sha1Item[0];
|
||||||
if (!(checksum instanceof ArrayBuffer)) {
|
if (!(checksum instanceof ArrayBuffer)) {
|
||||||
throw new BadRequestException(`Invalid 'sha' in 'repr-digest' header`);
|
throw new BadRequestException(`Invalid 'sha' in 'repr-digest' header`);
|
||||||
|
|
@ -360,22 +462,7 @@ export class AssetUploadService extends BaseService {
|
||||||
return Buffer.from(checksum);
|
return Buffer.from(checksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getIsCompleteOrThrow(headers: Request['headers']): boolean {
|
private requireAssetData(headers: Request['headers']): UploadAssetDataDto {
|
||||||
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 {
|
|
||||||
const value = headers[ImmichHeader.AssetData] as string | undefined;
|
const value = headers[ImmichHeader.AssetData] as string | undefined;
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
throw new BadRequestException(`Missing ${ImmichHeader.AssetData} header`);
|
throw new BadRequestException(`Missing ${ImmichHeader.AssetData} header`);
|
||||||
|
|
@ -387,64 +474,14 @@ export class AssetUploadService extends BaseService {
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException(`${ImmichHeader.AssetData} header is not valid base64-encoded JSON`);
|
throw new BadRequestException(`${ImmichHeader.AssetData} header is not valid base64-encoded JSON`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dto = plainToInstance(UploadAssetDataDto, assetData);
|
const dto = plainToInstance(UploadAssetDataDto, assetData);
|
||||||
const assetDataErrors = validateSync(dto, { whitelist: true });
|
const errors = validateSync(dto, { whitelist: true });
|
||||||
if (assetDataErrors.length > 0) {
|
if (errors.length > 0) {
|
||||||
const formatted = assetDataErrors.map((e) => (e.constraints ? Object.values(e.constraints).join(', ') : ''));
|
const formatted = errors.map((e) => (e.constraints ? Object.values(e.constraints).join(', ') : ''));
|
||||||
throw new BadRequestException(`Invalid ${ImmichHeader.AssetData} header: ${formatted.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;
|
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}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export const getKyselyConfig = (
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
log(event) {
|
log(event) {
|
||||||
if (event.level === 'error') {
|
if (event.level === 'error' && (event.error as PostgresError).constraint_name !== ASSET_CHECKSUM_CONSTRAINT) {
|
||||||
console.error('Query failed :', {
|
console.error('Query failed :', {
|
||||||
durationMs: event.queryDurationMillis,
|
durationMs: event.queryDurationMillis,
|
||||||
error: event.error,
|
error: event.error,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue