d6b6962604
## Context Follow-up to #22449 (direct-to-storage upload endpoints). In that flow `createFileUpload` inserts a `PENDING` file record before any bytes exist, and until now it guessed the mime type from the **filename extension** — an untrusted, client-controlled value. This PR makes a pending file opaque and only trusts a mime type that was verified against the actual stored bytes. ## What this does **1. A pending file is always `application/octet-stream`.** `createFileUpload` records the pending file — and signs the presigned PUT — as `application/octet-stream`. The extension is still kept on the stored object name so the content can be checked against it later. **2. Content verification at completion.** `completeFileUpload`, after the existing size check, reads a **bounded prefix** of the stored object (`readReadablePrefix`, capped at 64 KiB — a large object is never buffered in full) and runs the existing `extractFileInfoOrThrow` util to detect the real mime type from the content. It: - writes the detected type alongside `status = UPLOADED`, and - rejects a file whose bytes don't match its declared extension (the record stays `PENDING`, so it can never be served or attached, and is reaped by the pending-file cleanup cron). Serving already overrides `Content-Type` from the DB record, so storing the object as octet-stream is fine. **3. A database constraint as backstop.** `CHK_FILE_PENDING_MIME_OCTET_STREAM` — `"status" != 'PENDING' OR "mimeType" = 'application/octet-stream'` — added to `FileEntity` and applied by a fast instance command (`2-19`). It is added `NOT VALID` on purpose: an instance freshly upgraded past #22449 may still hold `PENDING` rows whose mime came from the old extension-guess path, and `NOT VALID` enforces the invariant on every new/updated row without failing on that legacy backlog (those rows get overwritten to octet-stream when completed — `status` flips to `UPLOADED`, so the check passes — or are reaped while pending). ## Tests - `read-readable-prefix.spec.ts` — prefix reader: short source, early stop on a large source (asserts it tears the stream down without draining it), error propagation, empty stream. - `file-upload.service.spec.ts` — create records octet-stream; complete sniffs and sets the detected type, overrides a spoofed extension with the real content type, and rejects content that can't be matched to the declared extension. - `direct-file-upload.integration-spec.ts` — end-to-end case rejecting a `.png` upload whose bytes are plain text. ## Verification `typecheck` green, `lint:diff-with-main` clean, unit suites pass (17 tests). No GraphQL schema change, so no codegen drift. ## Scope Server-only, part of the incremental direct-upload rollout being split into small PRs. Independent of the reaper-cron PR (#22531). https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d --- _Generated by [Claude Code](https://claude.ai/code/session_015UH8KWmsB9zdYaog8MFG1d)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22533?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
333 lines
9.7 KiB
TypeScript
333 lines
9.7 KiB
TypeScript
import gql from 'graphql-tag';
|
|
import request from 'supertest';
|
|
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
|
|
import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util';
|
|
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
|
import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util';
|
|
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
|
import { FieldMetadataType, FileFolder } from 'twenty-shared/types';
|
|
|
|
const createFileUploadMutation = gql`
|
|
mutation CreateFileUpload(
|
|
$filename: String!
|
|
$size: Float!
|
|
$fileFolder: FileFolder!
|
|
$fieldMetadataId: String
|
|
) {
|
|
createFileUpload(
|
|
filename: $filename
|
|
size: $size
|
|
fileFolder: $fileFolder
|
|
fieldMetadataId: $fieldMetadataId
|
|
) {
|
|
fileId
|
|
uploadUrl
|
|
contentType
|
|
expiresAt
|
|
}
|
|
}
|
|
`;
|
|
|
|
const completeFileUploadMutation = gql`
|
|
mutation CompleteFileUpload($fileId: String!) {
|
|
completeFileUpload(fileId: $fileId) {
|
|
id
|
|
path
|
|
size
|
|
createdAt
|
|
url
|
|
}
|
|
}
|
|
`;
|
|
|
|
const deleteFileMutation = gql`
|
|
mutation DeleteFile($fileId: UUID!) {
|
|
deleteFile(fileId: $fileId) {
|
|
id
|
|
}
|
|
}
|
|
`;
|
|
|
|
describe('direct file upload (createFileUpload / completeFileUpload)', () => {
|
|
let createdObjectMetadataId: string;
|
|
let createdFieldMetadataId: string;
|
|
const uploadedFileIds: string[] = [];
|
|
|
|
const createFileUpload = async (variables: Record<string, unknown>) => {
|
|
return makeMetadataAPIRequest({
|
|
query: createFileUploadMutation,
|
|
variables,
|
|
});
|
|
};
|
|
|
|
const completeFileUpload = async (fileId: string) => {
|
|
return makeMetadataAPIRequest({
|
|
query: completeFileUploadMutation,
|
|
variables: { fileId },
|
|
});
|
|
};
|
|
|
|
const putFileToUploadUrl = async (
|
|
uploadUrl: string,
|
|
contentType: string,
|
|
content: Buffer,
|
|
) => {
|
|
// Integration tests run on the local storage driver, so the upload url
|
|
// targets the server's streaming endpoint: replay it against the test app.
|
|
const { pathname, search } = new URL(uploadUrl);
|
|
|
|
return request(global.app.getHttpServer())
|
|
.put(`${pathname}${search}`)
|
|
.set('Content-Type', contentType)
|
|
.send(content);
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
jest.useRealTimers();
|
|
|
|
const {
|
|
data: {
|
|
createOneObject: { id: objectMetadataId },
|
|
},
|
|
} = await createOneObjectMetadata({
|
|
input: {
|
|
nameSingular: 'directUploadTestObject',
|
|
namePlural: 'directUploadTestObjects',
|
|
labelSingular: 'Direct Upload Test Object',
|
|
labelPlural: 'Direct Upload Test Objects',
|
|
icon: 'IconFile',
|
|
},
|
|
});
|
|
|
|
createdObjectMetadataId = objectMetadataId;
|
|
|
|
const {
|
|
data: {
|
|
createOneField: { id: fieldMetadataId },
|
|
},
|
|
} = await createOneFieldMetadata({
|
|
input: {
|
|
name: 'filesField',
|
|
label: 'Files Field',
|
|
type: FieldMetadataType.FILES,
|
|
objectMetadataId: createdObjectMetadataId,
|
|
settings: { maxNumberOfValues: 5 },
|
|
},
|
|
gqlFields: `
|
|
id
|
|
`,
|
|
});
|
|
|
|
createdFieldMetadataId = fieldMetadataId;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const fileId of uploadedFileIds) {
|
|
try {
|
|
await makeMetadataAPIRequest({
|
|
query: deleteFileMutation,
|
|
variables: { fileId },
|
|
});
|
|
} catch {
|
|
// Cleanup is best-effort: a failed deletion must not prevent the
|
|
// object metadata teardown below.
|
|
}
|
|
}
|
|
|
|
await updateOneObjectMetadata({
|
|
expectToFail: false,
|
|
input: {
|
|
idToUpdate: createdObjectMetadataId,
|
|
updatePayload: {
|
|
isActive: false,
|
|
},
|
|
},
|
|
});
|
|
await deleteOneObjectMetadata({
|
|
input: { idToDelete: createdObjectMetadataId },
|
|
});
|
|
|
|
jest.useFakeTimers();
|
|
});
|
|
|
|
it('should upload a file end to end: initiate, PUT bytes, complete', async () => {
|
|
const testFileContent = Buffer.from('direct upload test content');
|
|
|
|
const createResponse = await createFileUpload({
|
|
filename: 'direct-upload.txt',
|
|
size: testFileContent.length,
|
|
fileFolder: 'FilesField',
|
|
fieldMetadataId: createdFieldMetadataId,
|
|
});
|
|
|
|
expect(createResponse.status).toBe(200);
|
|
expect(createResponse.body.errors).toBeUndefined();
|
|
|
|
const uploadTarget = createResponse.body.data.createFileUpload;
|
|
|
|
expect(uploadTarget.fileId).toBeDefined();
|
|
expect(uploadTarget.uploadUrl).toContain(
|
|
`/file-upload/${uploadTarget.fileId}?token=`,
|
|
);
|
|
expect(uploadTarget.contentType).toBe('application/octet-stream');
|
|
expect(new Date(uploadTarget.expiresAt).getTime()).toBeGreaterThan(
|
|
Date.now(),
|
|
);
|
|
|
|
uploadedFileIds.push(uploadTarget.fileId);
|
|
|
|
const putResponse = await putFileToUploadUrl(
|
|
uploadTarget.uploadUrl,
|
|
uploadTarget.contentType,
|
|
testFileContent,
|
|
);
|
|
|
|
expect(putResponse.status).toBe(204);
|
|
|
|
const completeResponse = await completeFileUpload(uploadTarget.fileId);
|
|
|
|
expect(completeResponse.status).toBe(200);
|
|
expect(completeResponse.body.errors).toBeUndefined();
|
|
|
|
const completedFile = completeResponse.body.data.completeFileUpload;
|
|
|
|
expect(completedFile.id).toBe(uploadTarget.fileId);
|
|
expect(completedFile.path).toContain(FileFolder.FilesField);
|
|
expect(completedFile.size).toBe(testFileContent.length);
|
|
expect(completedFile.url).toContain(
|
|
`/file/${FileFolder.FilesField}/${uploadTarget.fileId}?token=`,
|
|
);
|
|
|
|
// The confirmed file is downloadable through the regular file endpoint
|
|
const { pathname, search } = new URL(completedFile.url);
|
|
const downloadResponse = await request(global.app.getHttpServer()).get(
|
|
`${pathname}${search}`,
|
|
);
|
|
|
|
expect(downloadResponse.status).toBe(200);
|
|
expect(downloadResponse.text).toBe(testFileContent.toString());
|
|
});
|
|
|
|
it('should refuse to complete an upload whose bytes never reached storage', async () => {
|
|
const createResponse = await createFileUpload({
|
|
filename: 'never-uploaded.txt',
|
|
size: 100,
|
|
fileFolder: 'FilesField',
|
|
fieldMetadataId: createdFieldMetadataId,
|
|
});
|
|
|
|
const uploadTarget = createResponse.body.data.createFileUpload;
|
|
|
|
uploadedFileIds.push(uploadTarget.fileId);
|
|
|
|
const completeResponse = await completeFileUpload(uploadTarget.fileId);
|
|
|
|
expect(completeResponse.body.errors).toBeDefined();
|
|
expect(completeResponse.body.errors[0].message).toContain(
|
|
'has not been uploaded',
|
|
);
|
|
});
|
|
|
|
it('should reject completing an upload whose content contradicts its extension', async () => {
|
|
const spoofedContent = Buffer.from('this is not really a png image');
|
|
|
|
const createResponse = await createFileUpload({
|
|
filename: 'actually-text.png',
|
|
size: spoofedContent.length,
|
|
fileFolder: 'FilesField',
|
|
fieldMetadataId: createdFieldMetadataId,
|
|
});
|
|
|
|
const uploadTarget = createResponse.body.data.createFileUpload;
|
|
|
|
uploadedFileIds.push(uploadTarget.fileId);
|
|
|
|
const putResponse = await putFileToUploadUrl(
|
|
uploadTarget.uploadUrl,
|
|
uploadTarget.contentType,
|
|
spoofedContent,
|
|
);
|
|
|
|
expect(putResponse.status).toBe(204);
|
|
|
|
const completeResponse = await completeFileUpload(uploadTarget.fileId);
|
|
|
|
expect(completeResponse.body.errors).toBeDefined();
|
|
expect(completeResponse.body.errors[0].message).toContain(
|
|
'does not match its extension',
|
|
);
|
|
|
|
const retryResponse = await completeFileUpload(uploadTarget.fileId);
|
|
|
|
expect(retryResponse.body.errors).toBeDefined();
|
|
expect(retryResponse.body.data?.completeFileUpload ?? null).toBeNull();
|
|
});
|
|
|
|
it('should refuse a PUT larger than the declared size', async () => {
|
|
const declaredSize = 10;
|
|
const oversizedContent = Buffer.from(
|
|
'this content is longer than ten bytes',
|
|
);
|
|
|
|
const createResponse = await createFileUpload({
|
|
filename: 'oversized.txt',
|
|
size: declaredSize,
|
|
fileFolder: 'FilesField',
|
|
fieldMetadataId: createdFieldMetadataId,
|
|
});
|
|
|
|
const uploadTarget = createResponse.body.data.createFileUpload;
|
|
|
|
uploadedFileIds.push(uploadTarget.fileId);
|
|
|
|
const putResponse = await putFileToUploadUrl(
|
|
uploadTarget.uploadUrl,
|
|
uploadTarget.contentType,
|
|
oversizedContent,
|
|
);
|
|
|
|
expect(putResponse.status).toBe(413);
|
|
});
|
|
|
|
it('should refuse a PUT without a valid upload token', async () => {
|
|
const createResponse = await createFileUpload({
|
|
filename: 'bad-token.txt',
|
|
size: 10,
|
|
fileFolder: 'FilesField',
|
|
fieldMetadataId: createdFieldMetadataId,
|
|
});
|
|
|
|
const uploadTarget = createResponse.body.data.createFileUpload;
|
|
|
|
uploadedFileIds.push(uploadTarget.fileId);
|
|
|
|
const putResponse = await request(global.app.getHttpServer())
|
|
.put(`/file-upload/${uploadTarget.fileId}?token=not-a-valid-token`)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from('0123456789'));
|
|
|
|
expect(putResponse.status).toBe(403);
|
|
});
|
|
|
|
it('should reject file folders without direct upload support', async () => {
|
|
const createResponse = await createFileUpload({
|
|
filename: 'picture.png',
|
|
size: 10,
|
|
fileFolder: 'CorePicture',
|
|
});
|
|
|
|
expect(createResponse.body.errors).toBeDefined();
|
|
});
|
|
|
|
it('should reject a size above the direct upload maximum', async () => {
|
|
const createResponse = await createFileUpload({
|
|
filename: 'huge.bin',
|
|
size: 5 * 1024 * 1024 * 1024,
|
|
fileFolder: 'FilesField',
|
|
fieldMetadataId: createdFieldMetadataId,
|
|
});
|
|
|
|
expect(createResponse.body.errors).toBeDefined();
|
|
});
|
|
});
|