Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6b5bf8bd6 | ||
|
|
34db7ac8b4 | ||
|
|
4a82cddad6 | ||
|
|
9d6c5b7d58 | ||
|
|
a2db0b6932 | ||
|
|
5eb79e7797 | ||
|
|
f19647617a | ||
|
|
424c6737a1 | ||
|
|
be8c25dfc2 | ||
|
|
b8de469f37 | ||
|
|
c74337978b | ||
|
|
059e75e532 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Feldtypen mit ähnlichen Namen können völlig unterschiedliche Operanden verwenden – `SELECT` und `MULTI_SELECT` sind ein häufiges Beispiel.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Tipos de campos com nomes semelhantes podem usar operandos completamente diferentes — `SELECT` e `MULTI_SELECT` sendo um caso comum.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Tipurile de câmp cu nume similare pot folosi operanzi complet diferiți — `SELECT` și `MULTI_SELECT` fiind un caz comun.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Типы полей с похожими названиями могут использовать совершенно разные операнды — типичный пример: `SELECT` и `MULTI_SELECT`.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Benzer adlara sahip alan türleri tamamen farklı işleçler kullanabilir — buna `SELECT` ve `MULTI_SELECT` yaygın bir örnektir.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> 名称相似的字段类型可以使用完全不同的运算符 —— `SELECT` 和 `MULTI_SELECT` 就是常见情况。
|
||||
|
||||
-73
@@ -1,10 +1,8 @@
|
||||
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { type WorkflowAction } from '~/generated/graphql';
|
||||
|
||||
const mockUpdateWorkflowVersionStep = jest.fn();
|
||||
const mockGetUpdatableWorkflowVersion = jest.fn();
|
||||
const mockMarkStepForRecomputation = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep',
|
||||
@@ -21,12 +19,6 @@ jest.mock('@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
|
||||
useStepsOutputSchema: jest.fn(() => ({
|
||||
markStepForRecomputation: mockMarkStepForRecomputation,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('useUpdateStep', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -69,69 +61,4 @@ describe('useUpdateStep', () => {
|
||||
step: mockStep,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark step for recomputation after update', async () => {
|
||||
const mockWorkflowVersionId = 'version-123';
|
||||
const mockStep = {
|
||||
id: 'step-1',
|
||||
name: 'Create Record',
|
||||
valid: true,
|
||||
type: 'CREATE_RECORD' as const,
|
||||
settings: {
|
||||
input: {
|
||||
objectName: 'company',
|
||||
},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
|
||||
|
||||
const { result } = renderHook(() => useUpdateStep());
|
||||
await act(async () => {
|
||||
await result.current.updateStep(mockStep as WorkflowAction);
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: 'step-1',
|
||||
workflowVersionId: mockWorkflowVersionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark step for recomputation for all step types', async () => {
|
||||
const mockWorkflowVersionId = 'version-123';
|
||||
const stepTypes = ['CODE', 'HTTP_REQUEST', 'CREATE_RECORD', 'SEND_EMAIL'];
|
||||
|
||||
for (const stepType of stepTypes) {
|
||||
mockMarkStepForRecomputation.mockClear();
|
||||
mockGetUpdatableWorkflowVersion.mockResolvedValue(mockWorkflowVersionId);
|
||||
|
||||
const mockStep = {
|
||||
id: `step-${stepType}`,
|
||||
name: `${stepType} Step`,
|
||||
valid: true,
|
||||
type: stepType as any,
|
||||
settings: {
|
||||
input: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useUpdateStep());
|
||||
await act(async () => {
|
||||
await result.current.updateStep(mockStep as WorkflowAction);
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: `step-${stepType}`,
|
||||
workflowVersionId: mockWorkflowVersionId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
const mockMutate = jest.fn();
|
||||
const mockGetRecordFromCache = jest.fn();
|
||||
const mockMarkStepForRecomputation = jest.fn();
|
||||
const mockEnqueueErrorSnackBar = jest.fn();
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useApolloCoreClient', () => ({
|
||||
useApolloCoreClient: () => ({ cache: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItems', () => ({
|
||||
useObjectMetadataItems: () => ({ objectMetadataItems: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({
|
||||
useObjectMetadataItem: () => ({ objectMetadataItem: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/hooks/useObjectPermissions', () => ({
|
||||
useObjectPermissions: () => ({ objectPermissionsByObjectMetadataId: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({ enqueueErrorSnackBar: mockEnqueueErrorSnackBar }),
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/cache/hooks/useGetRecordFromCache', () => ({
|
||||
useGetRecordFromCache: () => mockGetRecordFromCache,
|
||||
}));
|
||||
|
||||
jest.mock('@/object-record/cache/utils/updateRecordFromCache', () => ({
|
||||
updateRecordFromCache: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/workflow/workflow-variables/hooks/useStepsOutputSchema', () => ({
|
||||
useStepsOutputSchema: () => ({
|
||||
markStepForRecomputation: mockMarkStepForRecomputation,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@apollo/client/react', () => ({
|
||||
useMutation: () => [mockMutate],
|
||||
}));
|
||||
|
||||
describe('useUpdateWorkflowVersionStep', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should mark step for recomputation after successful update', async () => {
|
||||
const updatedStep = {
|
||||
id: 'step-1',
|
||||
name: 'My Custom Name',
|
||||
type: 'CREATE_RECORD',
|
||||
};
|
||||
|
||||
mockMutate.mockResolvedValue({
|
||||
data: { updateWorkflowVersionStep: updatedStep },
|
||||
});
|
||||
|
||||
mockGetRecordFromCache.mockReturnValue({
|
||||
id: 'version-1',
|
||||
steps: [{ id: 'step-1', name: 'Create Record', type: 'CREATE_RECORD' }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateWorkflowVersionStep({
|
||||
workflowVersionId: 'version-1',
|
||||
step: updatedStep,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: 'step-1',
|
||||
workflowVersionId: 'version-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not mark step for recomputation when mutation returns no data', async () => {
|
||||
mockMutate.mockResolvedValue({ data: null });
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateWorkflowVersionStep({
|
||||
workflowVersionId: 'version-1',
|
||||
step: { id: 'step-1', name: 'Step', type: 'CODE' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should still mark step for recomputation when cached record is missing', async () => {
|
||||
mockMutate.mockResolvedValue({
|
||||
data: {
|
||||
updateWorkflowVersionStep: {
|
||||
id: 'step-1',
|
||||
name: 'Step',
|
||||
type: 'CODE',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockGetRecordFromCache.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useUpdateWorkflowVersionStep());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateWorkflowVersionStep({
|
||||
workflowVersionId: 'version-1',
|
||||
step: { id: 'step-1', name: 'Step', type: 'CODE' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockMarkStepForRecomputation).toHaveBeenCalledWith({
|
||||
stepId: 'step-1',
|
||||
workflowVersionId: 'version-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import { type WorkflowAction } from '@/workflow/types/Workflow';
|
||||
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
|
||||
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
|
||||
|
||||
export const useUpdateStep = () => {
|
||||
const { getUpdatableWorkflowVersion } =
|
||||
useGetUpdatableWorkflowVersionOrThrow();
|
||||
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
|
||||
const { markStepForRecomputation } = useStepsOutputSchema();
|
||||
|
||||
const updateStep = async (updatedStep: WorkflowAction) => {
|
||||
const workflowVersionId = await getUpdatableWorkflowVersion();
|
||||
@@ -17,11 +15,6 @@ export const useUpdateStep = () => {
|
||||
step: updatedStep,
|
||||
});
|
||||
|
||||
markStepForRecomputation({
|
||||
stepId: updatedStep.id,
|
||||
workflowVersionId,
|
||||
});
|
||||
|
||||
return {
|
||||
updatedStep: result?.data?.updateWorkflowVersionStep,
|
||||
};
|
||||
|
||||
+9
-1
@@ -10,6 +10,7 @@ import {
|
||||
type WorkflowVersion,
|
||||
type WorkflowStep,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -24,6 +25,7 @@ export const useUpdateWorkflowVersionStep = () => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { markStepForRecomputation } = useStepsOutputSchema();
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
|
||||
@@ -52,11 +54,16 @@ export const useUpdateWorkflowVersionStep = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
markStepForRecomputation({
|
||||
stepId: updatedStep.id,
|
||||
workflowVersionId: input.workflowVersionId,
|
||||
});
|
||||
|
||||
const cachedRecord = getRecordFromCache<WorkflowVersion>(
|
||||
input.workflowVersionId,
|
||||
);
|
||||
if (!isDefined(cachedRecord)) {
|
||||
return;
|
||||
return result;
|
||||
}
|
||||
|
||||
const newCachedRecord = {
|
||||
@@ -80,6 +87,7 @@ export const useUpdateWorkflowVersionStep = () => {
|
||||
recordGqlFields,
|
||||
objectPermissionsByObjectMetadataId,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"sideEffects": false,
|
||||
"bin": {
|
||||
"twenty": "dist/cli.cjs"
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
getInputSchemaFromSourceCode,
|
||||
jsonSchemaToInputSchema,
|
||||
} from 'twenty-shared/logic-function';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const loadSources = async (appPath: string): Promise<string[]> => {
|
||||
return await glob(['**/*.ts', '**/*.tsx'], {
|
||||
@@ -358,7 +358,7 @@ export const buildManifest = async (
|
||||
errors.push(...extract.errors);
|
||||
warnings.push(...(extract.warnings ?? []));
|
||||
|
||||
const { component, ...rest } = extract.config;
|
||||
const { component, command, ...rest } = extract.config;
|
||||
|
||||
const relativeFilePath = relative(appPath, filePath);
|
||||
|
||||
@@ -374,6 +374,14 @@ export const buildManifest = async (
|
||||
frontComponents.push(config);
|
||||
frontComponentsFilePaths.push(relativePath);
|
||||
|
||||
if (isDefined(command)) {
|
||||
commandMenuItems.push({
|
||||
...command,
|
||||
frontComponentUniversalIdentifier: config.universalIdentifier,
|
||||
} as unknown as CommandMenuItemManifest);
|
||||
commandMenuItemsFilePaths.push(relativePath);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case ManifestEntityKey.Views: {
|
||||
|
||||
@@ -19,6 +19,16 @@ export const defineFrontComponent: DefineEntity<FrontComponentConfig> = (
|
||||
errors.push('Front component component must be a React component');
|
||||
}
|
||||
|
||||
if (config.command) {
|
||||
if (!config.command.universalIdentifier) {
|
||||
errors.push('Command must have a universalIdentifier');
|
||||
}
|
||||
|
||||
if (!config.command.label) {
|
||||
errors.push('Command must have a label');
|
||||
}
|
||||
}
|
||||
|
||||
return createValidationResult({
|
||||
config,
|
||||
errors,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { type FrontComponentManifest } from 'twenty-shared/application';
|
||||
import {
|
||||
type FrontComponentCommandManifest,
|
||||
type FrontComponentManifest,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
export type FrontComponentType = React.ComponentType<any>;
|
||||
|
||||
export type FrontComponentCommandConfig = Omit<
|
||||
FrontComponentCommandManifest,
|
||||
'conditionalAvailabilityExpression'
|
||||
> & {
|
||||
conditionalAvailabilityExpression?: boolean | string;
|
||||
};
|
||||
|
||||
export type FrontComponentConfig = Omit<
|
||||
FrontComponentManifest,
|
||||
| 'sourceComponentPath'
|
||||
@@ -11,4 +21,5 @@ export type FrontComponentConfig = Omit<
|
||||
| 'usesSdkClient'
|
||||
> & {
|
||||
component: FrontComponentType;
|
||||
command?: FrontComponentCommandConfig;
|
||||
};
|
||||
|
||||
@@ -96,5 +96,8 @@ FRONTEND_URL=http://localhost:3001
|
||||
# Add custom providers (private gateway, extra regions, etc.):
|
||||
# AI_PROVIDERS='{"my-gateway":{"type":"openai-compatible","baseUrl":"...","apiKey":"..."}}'
|
||||
#
|
||||
# Override model preferences (disabled, recommended, defaults):
|
||||
# AI_MODEL_PREFERENCES='{"recommendedModels":["openai/gpt-5.2"],"defaultFastModels":["openai/gpt-5-mini"]}'
|
||||
# Comma-separated model IDs (JSON array syntax also accepted, e.g. '["a","b"]'):
|
||||
# AI_MODELS_DEFAULT_FAST=openai/gpt-5-mini,anthropic/claude-haiku-4-5-20251001
|
||||
# AI_MODELS_DEFAULT_SMART=openai/gpt-5.2,anthropic/claude-sonnet-4-6
|
||||
# AI_MODELS_DEFAULT_RECOMMENDED=openai/gpt-5.2,openai/gpt-4.1,anthropic/claude-sonnet-4-6
|
||||
# AI_MODELS_DEFAULT_DISABLED=
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource, IsNull, QueryRunner } from 'typeorm';
|
||||
|
||||
import {
|
||||
KeyValuePairEntity,
|
||||
KeyValuePairType,
|
||||
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
|
||||
import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema';
|
||||
|
||||
const NEW_KEYS = [
|
||||
'AI_MODELS_DEFAULT_FAST',
|
||||
'AI_MODELS_DEFAULT_SMART',
|
||||
'AI_MODELS_DEFAULT_RECOMMENDED',
|
||||
'AI_MODELS_DEFAULT_DISABLED',
|
||||
] as const;
|
||||
|
||||
const PREFERENCE_KEY_MAP = {
|
||||
AI_MODELS_DEFAULT_FAST: 'defaultFastModels',
|
||||
AI_MODELS_DEFAULT_SMART: 'defaultSmartModels',
|
||||
AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels',
|
||||
AI_MODELS_DEFAULT_DISABLED: 'disabledModels',
|
||||
} as const;
|
||||
|
||||
@RegisteredInstanceCommand('2.9.0', 1799000010000, { type: 'slow' })
|
||||
export class MigrateAiModelPreferencesSlowInstanceCommand
|
||||
implements SlowInstanceCommand
|
||||
{
|
||||
private readonly logger = new Logger(
|
||||
MigrateAiModelPreferencesSlowInstanceCommand.name,
|
||||
);
|
||||
|
||||
async runDataMigration(dataSource: DataSource): Promise<void> {
|
||||
const keyValuePairRepository = dataSource.getRepository(KeyValuePairEntity);
|
||||
|
||||
const existingRow = await keyValuePairRepository.findOne({
|
||||
where: {
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: 'AI_MODEL_PREFERENCES',
|
||||
userId: IsNull(),
|
||||
workspaceId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(existingRow)) {
|
||||
this.logger.log(
|
||||
'No server-level AI_MODEL_PREFERENCES row found, skipping',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = aiModelPreferencesSchema.safeParse(existingRow.value);
|
||||
|
||||
if (!parseResult.success) {
|
||||
this.logger.error(
|
||||
`Failed to parse server-level AI_MODEL_PREFERENCES: ${parseResult.error.message}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = parseResult.data;
|
||||
|
||||
this.logger.log('Migrating server-level AI_MODEL_PREFERENCES');
|
||||
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const transactionalRepository = manager.getRepository(KeyValuePairEntity);
|
||||
|
||||
for (const newKey of NEW_KEYS) {
|
||||
const prefField = PREFERENCE_KEY_MAP[newKey];
|
||||
const value = prefs[prefField];
|
||||
|
||||
if (!isDefined(value) || value.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingNewKeyCount = await transactionalRepository.count({
|
||||
where: {
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
key: newKey,
|
||||
userId: IsNull(),
|
||||
workspaceId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingNewKeyCount > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await transactionalRepository.insert({
|
||||
key: newKey,
|
||||
value: value as unknown as JSON,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
userId: null,
|
||||
workspaceId: null,
|
||||
});
|
||||
}
|
||||
|
||||
await transactionalRepository.delete({ id: existingRow.id });
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
'Migrated server-level AI_MODEL_PREFERENCES to 4 individual vars',
|
||||
);
|
||||
}
|
||||
|
||||
public async up(_queryRunner: QueryRunner): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { MigrateAiModelPreferencesCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-workspace-command-1799000000000-migrate-ai-model-preferences.command';
|
||||
import { KeyValuePairEntity } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([KeyValuePairEntity]),
|
||||
WorkspaceIteratorModule,
|
||||
],
|
||||
providers: [MigrateAiModelPreferencesCommand],
|
||||
})
|
||||
export class V2_9_UpgradeVersionCommandModule {}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { isArray, isDefined } from 'class-validator';
|
||||
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
|
||||
import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
|
||||
import {
|
||||
KeyValuePairEntity,
|
||||
KeyValuePairType,
|
||||
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
|
||||
import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema';
|
||||
|
||||
const NEW_KEYS = [
|
||||
'AI_MODELS_DEFAULT_FAST',
|
||||
'AI_MODELS_DEFAULT_SMART',
|
||||
'AI_MODELS_DEFAULT_RECOMMENDED',
|
||||
'AI_MODELS_DEFAULT_DISABLED',
|
||||
] as const;
|
||||
|
||||
const PREFERENCE_KEY_MAP = {
|
||||
AI_MODELS_DEFAULT_FAST: 'defaultFastModels',
|
||||
AI_MODELS_DEFAULT_SMART: 'defaultSmartModels',
|
||||
AI_MODELS_DEFAULT_RECOMMENDED: 'recommendedModels',
|
||||
AI_MODELS_DEFAULT_DISABLED: 'disabledModels',
|
||||
} as const;
|
||||
|
||||
@RegisteredWorkspaceCommand('2.9.0', 1799000000000)
|
||||
@Command({
|
||||
name: 'upgrade:2-9:migrate-ai-model-preferences',
|
||||
description:
|
||||
'Migrate AI_MODEL_PREFERENCES config var to the four individual AI_MODELS_DEFAULT_* vars, per workspace',
|
||||
})
|
||||
export class MigrateAiModelPreferencesCommand extends ActiveOrSuspendedWorkspaceCommandRunner {
|
||||
constructor(
|
||||
protected readonly workspaceIteratorService: WorkspaceIteratorService,
|
||||
@InjectRepository(KeyValuePairEntity)
|
||||
private readonly keyValuePairRepository: Repository<KeyValuePairEntity>,
|
||||
) {
|
||||
super(workspaceIteratorService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
const existingPreferencesRow = await this.keyValuePairRepository.findOne({
|
||||
where: {
|
||||
key: 'AI_MODEL_PREFERENCES',
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
workspaceId,
|
||||
userId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPreferencesRow === null) {
|
||||
this.logger.log(
|
||||
`No AI_MODEL_PREFERENCES row found for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = aiModelPreferencesSchema.safeParse(
|
||||
existingPreferencesRow.value,
|
||||
);
|
||||
|
||||
if (!parseResult.success) {
|
||||
this.logger.error(
|
||||
`Failed to parse AI_MODEL_PREFERENCES for workspace ${workspaceId}: ${parseResult.error.message}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const prefs = parseResult.data;
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Migrating AI_MODEL_PREFERENCES for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
for (const newKey of NEW_KEYS) {
|
||||
const prefField = PREFERENCE_KEY_MAP[newKey];
|
||||
if (
|
||||
!isDefined(prefField) ||
|
||||
(isArray(prefs[prefField]) && prefs[prefField].length === 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const value = prefs[prefField];
|
||||
|
||||
if (isDryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would insert ${newKey} = ${JSON.stringify(value)} for workspace ${workspaceId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingNewKeyRow = await this.keyValuePairRepository.findOne({
|
||||
where: {
|
||||
key: newKey,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
workspaceId,
|
||||
userId: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingNewKeyRow !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.keyValuePairRepository.insert({
|
||||
key: newKey,
|
||||
value: value as unknown as JSON,
|
||||
type: KeyValuePairType.CONFIG_VARIABLE,
|
||||
workspaceId,
|
||||
userId: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isDryRun) {
|
||||
await this.keyValuePairRepository.delete({
|
||||
id: existingPreferencesRow.id,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Migrated AI_MODEL_PREFERENCES to 4 individual vars for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -55,6 +55,7 @@ import { AddChannelSyncStageIndexesFastInstanceCommand } from 'src/database/comm
|
||||
import { FinalizeRolePermissionFlagCutoverFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-7/2-7-instance-command-fast-1779600000000-finalize-role-permission-flag-cutover';
|
||||
import { AddSubFieldNameToIndexFieldMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798200000000-add-sub-field-name-to-index-field-metadata';
|
||||
import { DropFieldMetadataIsUniqueColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-8/2-8-instance-command-fast-1798300000000-drop-field-metadata-is-unique-column';
|
||||
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -112,4 +113,5 @@ export const INSTANCE_COMMANDS = [
|
||||
FinalizeRolePermissionFlagCutoverFastInstanceCommand,
|
||||
AddSubFieldNameToIndexFieldMetadataFastInstanceCommand,
|
||||
DropFieldMetadataIsUniqueColumnFastInstanceCommand,
|
||||
MigrateAiModelPreferencesSlowInstanceCommand,
|
||||
];
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ import { V2_4_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
import { V2_5_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module';
|
||||
import { V2_7_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-7/2-7-upgrade-version-command.module';
|
||||
import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-8/2-8-upgrade-version-command.module';
|
||||
import { V2_9_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/2-9/2-9-upgrade-version-command.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { V2_8_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-
|
||||
V2_5_UpgradeVersionCommandModule,
|
||||
V2_7_UpgradeVersionCommandModule,
|
||||
V2_8_UpgradeVersionCommandModule,
|
||||
V2_9_UpgradeVersionCommandModule,
|
||||
],
|
||||
})
|
||||
export class WorkspaceCommandProviderModule {}
|
||||
|
||||
+2
-3
@@ -20,7 +20,6 @@ import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/a
|
||||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
|
||||
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
|
||||
@@ -192,7 +192,7 @@ export class ApplicationDevelopmentResolver {
|
||||
async uploadApplicationFile(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, mimetype }: FileUpload,
|
||||
{ createReadStream }: FileUpload,
|
||||
@Args()
|
||||
{
|
||||
applicationUniversalIdentifier,
|
||||
@@ -250,7 +250,6 @@ export class ApplicationDevelopmentResolver {
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
sourceFile: buffer,
|
||||
mimeType: mimetype,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
+1
-10
@@ -5,8 +5,6 @@ import { promises as fs } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import semver from 'semver';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { Manifest } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -493,15 +491,8 @@ export class ApplicationInstallService {
|
||||
);
|
||||
}
|
||||
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file: content,
|
||||
filename: relativePath,
|
||||
});
|
||||
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedContent,
|
||||
mimeType,
|
||||
sourceFile: content,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
-1
@@ -190,7 +190,6 @@ export class ApplicationTarballService {
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: params.tarballBuffer,
|
||||
resourcePath: `${appRegistration.id}/app.tar.gz`,
|
||||
mimeType: 'application/gzip',
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
@@ -411,7 +411,6 @@ export class ApplicationService {
|
||||
|
||||
const packageJsonFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: defaultPackageFields.packageJsonContent,
|
||||
mimeType: undefined,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId: application.workspaceId,
|
||||
@@ -422,7 +421,6 @@ export class ApplicationService {
|
||||
|
||||
const yarnLockFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: defaultPackageFields.yarnLockContent,
|
||||
mimeType: undefined,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId: application.workspaceId,
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
describe('FileStorageExceptionFilter', () => {
|
||||
const filter = new FileStorageExceptionFilter();
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
expectedError: UserInputError,
|
||||
},
|
||||
{
|
||||
code: FileStorageExceptionCode.ACCESS_DENIED,
|
||||
expectedError: ForbiddenError,
|
||||
},
|
||||
{
|
||||
code: FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
expectedError: NotFoundError,
|
||||
},
|
||||
])(
|
||||
'should map $code to the expected GraphQL error',
|
||||
({ code, expectedError }) => {
|
||||
const exception = new FileStorageException('test message', code);
|
||||
|
||||
expect(() => filter.catch(exception)).toThrow(expectedError);
|
||||
},
|
||||
);
|
||||
});
|
||||
+135
-2
@@ -381,7 +381,6 @@ describe('FileStorageService', () => {
|
||||
...validResourceIdentifier,
|
||||
resourcePath: context.resourcePath,
|
||||
sourceFile: Buffer.from('malicious'),
|
||||
mimeType: 'application/javascript',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
@@ -396,7 +395,6 @@ describe('FileStorageService', () => {
|
||||
await service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
mimeType: 'application/javascript',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
@@ -406,6 +404,141 @@ describe('FileStorageService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('magic-byte backstop', () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
]);
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
it('should reject buffer whose magic bytes do not match the path extension', async () => {
|
||||
await expect(
|
||||
service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/fake-image.png',
|
||||
sourceFile: textBuffer,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept buffer whose magic bytes match the path extension and persist the bytes-derived mime', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/photo.png',
|
||||
sourceFile: pngBuffer,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
);
|
||||
expect(mockFileRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist application/typescript for a TypeScript source string (TWENTY_MIME_POLICY)', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'src/index.tsx',
|
||||
sourceFile: 'export const App = () => null;',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'application/typescript' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist application/typescript for a TypeScript source buffer (policy beats mrmime collision)', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'src/handler.ts',
|
||||
sourceFile: Buffer.from(
|
||||
'export const handler = () => null;',
|
||||
'utf-8',
|
||||
),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'application/typescript' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG sanitization (centralized invariant)', () => {
|
||||
const maliciousSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>';
|
||||
|
||||
it('should strip scripts and event handlers from an SVG string upload', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
sourceFile: maliciousSvg,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.mimeType).toBe('image/svg+xml');
|
||||
expect(typeof driverCall.sourceFile).toBe('string');
|
||||
expect(driverCall.sourceFile).not.toContain('<script>');
|
||||
expect(driverCall.sourceFile).not.toContain('onload');
|
||||
expect(driverCall.sourceFile).toContain('<circle');
|
||||
});
|
||||
|
||||
it('should strip scripts and event handlers from an SVG buffer upload', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.mimeType).toBe('image/svg+xml');
|
||||
expect(driverCall.sourceFile).not.toContain('<script>');
|
||||
expect(driverCall.sourceFile).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('should leave non-SVG content untouched (sanitize is no-op)', async () => {
|
||||
const original = '{"foo": "bar"}';
|
||||
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'package.json',
|
||||
sourceFile: original,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.sourceFile).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(FileStorageException)
|
||||
export class FileStorageExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: FileStorageException) {
|
||||
switch (exception.code) {
|
||||
case FileStorageExceptionCode.INVALID_EXTENSION:
|
||||
throw new UserInputError(exception);
|
||||
case FileStorageExceptionCode.ACCESS_DENIED:
|
||||
throw new ForbiddenError(exception);
|
||||
case FileStorageExceptionCode.FILE_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type DynamicModule, Global } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -16,7 +18,14 @@ export class FileStorageModule {
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
|
||||
],
|
||||
providers: [FileStorageDriverFactory, FileStorageService],
|
||||
providers: [
|
||||
FileStorageDriverFactory,
|
||||
FileStorageService,
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: FileStorageExceptionFilter,
|
||||
},
|
||||
],
|
||||
exports: [FileStorageDriverFactory, FileStorageService],
|
||||
};
|
||||
}
|
||||
|
||||
+26
-17
@@ -13,6 +13,7 @@ import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { validateFolderPath } from 'src/engine/core-modules/file-storage/utils/validate-folder-path.util';
|
||||
import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util';
|
||||
@@ -66,7 +67,7 @@ export class FileStorageService {
|
||||
return { onStoragePath, resourcePath };
|
||||
}
|
||||
|
||||
private validateAndBuildFileStoragePath(params: ResourceIdentifier): {
|
||||
private validateAndBuildFileStoragePathOrThrow(params: ResourceIdentifier): {
|
||||
onStorageFilePath: string;
|
||||
filePath: string;
|
||||
} {
|
||||
@@ -91,7 +92,7 @@ export class FileStorageService {
|
||||
return { onStorageFilePath: onStoragePath, filePath: resourcePath };
|
||||
}
|
||||
|
||||
private validateAndBuildFolderStoragePath(
|
||||
private validateAndBuildFolderStoragePathOrThrow(
|
||||
params: Omit<ResourceIdentifier, 'resourcePath'> & { folderPath: string },
|
||||
): { onStorageFolderPath: string; folderPath: string } {
|
||||
const validationResult = validateFolderPath({
|
||||
@@ -119,7 +120,6 @@ export class FileStorageService {
|
||||
|
||||
async writeFile({
|
||||
sourceFile,
|
||||
mimeType,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
@@ -129,7 +129,6 @@ export class FileStorageService {
|
||||
queryRunner,
|
||||
}: ResourceIdentifier & {
|
||||
sourceFile: string | Buffer | Uint8Array;
|
||||
mimeType: string | undefined;
|
||||
fileId?: string;
|
||||
settings: FileSettings;
|
||||
queryRunner?: QueryRunner;
|
||||
@@ -151,17 +150,23 @@ export class FileStorageService {
|
||||
});
|
||||
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePath({
|
||||
this.validateAndBuildFileStoragePathOrThrow({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
const { sourceFile: persistedSourceFile, mimeType } =
|
||||
await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
await driver.writeFile({
|
||||
filePath: onStorageFilePath,
|
||||
mimeType,
|
||||
sourceFile,
|
||||
sourceFile: persistedSourceFile,
|
||||
});
|
||||
|
||||
await fileRepository.upsert(
|
||||
@@ -172,9 +177,9 @@ export class FileStorageService {
|
||||
id: fileId,
|
||||
mimeType,
|
||||
size:
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.byteLength(sourceFile)
|
||||
: sourceFile.length,
|
||||
typeof persistedSourceFile === 'string'
|
||||
? Buffer.byteLength(persistedSourceFile)
|
||||
: persistedSourceFile.length,
|
||||
settings,
|
||||
},
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
@@ -197,7 +202,8 @@ export class FileStorageService {
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.getPresignedUrl({
|
||||
filePath: onStorageFilePath,
|
||||
@@ -210,7 +216,8 @@ export class FileStorageService {
|
||||
readFile(params: ResourceIdentifier): Promise<Readable> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.readFile({ filePath: onStorageFilePath });
|
||||
}
|
||||
@@ -219,7 +226,8 @@ export class FileStorageService {
|
||||
params: ResourceIdentifier & { localPath: string },
|
||||
): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.downloadFile({
|
||||
onStoragePath: onStorageFilePath,
|
||||
@@ -256,7 +264,7 @@ export class FileStorageService {
|
||||
async deleteFile(params: ResourceIdentifier): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePath(params);
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
await driver.delete({
|
||||
folderPath: dirname(onStorageFilePath),
|
||||
@@ -288,7 +296,7 @@ export class FileStorageService {
|
||||
} = params;
|
||||
|
||||
const { onStorageFolderPath, folderPath: validatedFolderPath } =
|
||||
this.validateAndBuildFolderStoragePath({
|
||||
this.validateAndBuildFolderStoragePathOrThrow({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
@@ -373,9 +381,9 @@ export class FileStorageService {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const { onStorageFilePath: fromPath } =
|
||||
this.validateAndBuildFileStoragePath(from);
|
||||
this.validateAndBuildFileStoragePathOrThrow(from);
|
||||
const { onStorageFilePath: toPath } =
|
||||
this.validateAndBuildFileStoragePath(to);
|
||||
this.validateAndBuildFileStoragePathOrThrow(to);
|
||||
|
||||
const isFile = await driver.checkFileExists({ filePath: fromPath });
|
||||
|
||||
@@ -394,7 +402,8 @@ export class FileStorageService {
|
||||
|
||||
checkFileExists(params: ResourceIdentifier): Promise<boolean> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.checkFileExists({ filePath: onStorageFilePath });
|
||||
}
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
|
||||
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52,
|
||||
]);
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
const benignSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="10" /></svg>';
|
||||
const maliciousSvg = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>`;
|
||||
|
||||
describe('prepareFileForStorageOrThrow', () => {
|
||||
describe('mime resolution', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'TypeScript .ts string (policy)',
|
||||
sourceFile: 'export const x = 1;',
|
||||
resourcePath: 'src/index.ts',
|
||||
expectedMime: 'application/typescript',
|
||||
},
|
||||
{
|
||||
name: 'TypeScript .tsx string (policy)',
|
||||
sourceFile: 'export const App = () => null;',
|
||||
resourcePath: 'src/App.tsx',
|
||||
expectedMime: 'application/typescript',
|
||||
},
|
||||
{
|
||||
name: '.mjs string (mrmime)',
|
||||
sourceFile: 'export default 1;',
|
||||
resourcePath: 'src/handler.mjs',
|
||||
expectedMime: 'text/javascript',
|
||||
},
|
||||
{
|
||||
name: 'JSON string (mrmime)',
|
||||
sourceFile: '{"foo": "bar"}',
|
||||
resourcePath: 'package.json',
|
||||
expectedMime: 'application/json',
|
||||
},
|
||||
{
|
||||
name: 'unknown-extension string (octet-stream)',
|
||||
sourceFile: 'lock content',
|
||||
resourcePath: 'yarn.lock',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'no-extension string (octet-stream)',
|
||||
sourceFile: 'content',
|
||||
resourcePath: 'somefile',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'dot-file string (octet-stream — extname semantics)',
|
||||
sourceFile: 'content',
|
||||
resourcePath: '.gitignore',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
])(
|
||||
'returns $expectedMime for $name',
|
||||
async ({ sourceFile, resourcePath, expectedMime }) => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
expect(mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'PNG buffer',
|
||||
sourceFile: pngBuffer,
|
||||
resourcePath: 'images/photo.png',
|
||||
expectedMime: 'image/png',
|
||||
},
|
||||
{
|
||||
name: 'PDF buffer',
|
||||
sourceFile: pdfBuffer,
|
||||
resourcePath: 'docs/contract.pdf',
|
||||
expectedMime: 'application/pdf',
|
||||
},
|
||||
{
|
||||
name: 'ZIP buffer',
|
||||
sourceFile: zipBuffer,
|
||||
resourcePath: 'archives/data.zip',
|
||||
expectedMime: 'application/zip',
|
||||
},
|
||||
])(
|
||||
'returns the bytes-derived mime for $name',
|
||||
async ({ sourceFile, resourcePath, expectedMime }) => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
expect(mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the bytes-derived mime for a Uint8Array source', async () => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: new Uint8Array(pngBuffer),
|
||||
resourcePath: 'images/photo.png',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary-extension backstop', () => {
|
||||
it('throws when a buffer with a binary extension does not match the magic bytes', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: textBuffer,
|
||||
resourcePath: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when a string with a binary extension does not match the magic bytes', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: 'this is not a png',
|
||||
resourcePath: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on Uint8Array byte/extension mismatch', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: new Uint8Array(textBuffer),
|
||||
resourcePath: 'fake-image.pdf',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG sanitization', () => {
|
||||
it('sanitizes an SVG string by removing scripts and event handlers', async () => {
|
||||
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: maliciousSvg,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/svg+xml');
|
||||
expect(typeof sourceFile).toBe('string');
|
||||
expect(sourceFile).not.toContain('<script>');
|
||||
expect(sourceFile).not.toContain('onload');
|
||||
expect(sourceFile).toContain('<circle');
|
||||
});
|
||||
|
||||
it('sanitizes an SVG buffer (DOMPurify returns a string)', async () => {
|
||||
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/svg+xml');
|
||||
expect(typeof sourceFile).toBe('string');
|
||||
expect(sourceFile).not.toContain('<script>');
|
||||
expect(sourceFile).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('preserves a benign SVG (no destructive sanitization)', async () => {
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: benignSvg,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(sourceFile).toContain('<circle');
|
||||
expect(sourceFile).toContain('xmlns');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-SVG content is returned unchanged', () => {
|
||||
it('returns the original buffer reference for a non-SVG buffer', async () => {
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: pngBuffer,
|
||||
resourcePath: 'images/photo.png',
|
||||
});
|
||||
|
||||
expect(sourceFile).toBe(pngBuffer);
|
||||
});
|
||||
|
||||
it('returns the original string for a non-SVG string', async () => {
|
||||
const original = '{"foo": "bar"}';
|
||||
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: original,
|
||||
resourcePath: 'package.json',
|
||||
});
|
||||
|
||||
expect(sourceFile).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
|
||||
export const prepareFileForStorageOrThrow = async ({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
}: {
|
||||
sourceFile: Buffer | Uint8Array | string;
|
||||
resourcePath: string;
|
||||
}): Promise<{
|
||||
sourceFile: Buffer | Uint8Array | string;
|
||||
mimeType: string;
|
||||
}> => {
|
||||
const bufferForExtract =
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.from(sourceFile, 'utf8')
|
||||
: Buffer.isBuffer(sourceFile)
|
||||
? sourceFile
|
||||
: Buffer.from(sourceFile);
|
||||
|
||||
const { mimeType, ext } = await extractFileInfoOrThrow({
|
||||
file: bufferForExtract,
|
||||
filename: resourcePath,
|
||||
});
|
||||
|
||||
const sanitizedSourceFile = sanitizeFile({
|
||||
file: sourceFile,
|
||||
ext,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
return { sourceFile: sanitizedSourceFile, mimeType };
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// Extensions where Twenty intentionally deviates from the IANA-standard mime
|
||||
// mapping returned by mrmime. Twenty's storage layer persists these values
|
||||
// instead of mrmime's output (or instead of throwing in the case of `.ts`).
|
||||
//
|
||||
// Why each entry exists:
|
||||
// - ts/tsx: IANA registers `.ts` as `video/mp2t` (MPEG-2 Transport Stream),
|
||||
// which predates TypeScript. `.tsx` is unregistered. Twenty uses
|
||||
// the developer-tooling convention `application/typescript`.
|
||||
//
|
||||
// Only add an entry when (1) mrmime returns the wrong mime (collision) or no
|
||||
// mime AND (2) Twenty actually writes that extension via FileStorageService AND
|
||||
// (3) there is a clear developer-tooling convention for the right mime. For
|
||||
// everything else — media, archives, documents, `.js`, `.mjs`, `.cjs`, etc. —
|
||||
// trust mrmime. This policy is reactive: extend it the day we observe a real
|
||||
// wrong-mime persisted by an exercised code path, not in anticipation.
|
||||
export const TWENTY_MIME_POLICY: Record<string, string> = {
|
||||
ts: 'application/typescript',
|
||||
tsx: 'application/typescript',
|
||||
};
|
||||
+4
-7
@@ -8,8 +8,8 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileAiChatService {
|
||||
constructor(
|
||||
@@ -27,13 +27,11 @@ export class FileAiChatService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -45,9 +43,8 @@ export class FileAiChatService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
+3
-6
@@ -19,9 +19,8 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
@@ -72,8 +71,7 @@ export class FileCorePictureService {
|
||||
applicationUniversalIdentifier?: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileEntity> {
|
||||
const { mimeType, ext } = await extractFileInfo({ file, filename });
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
const { ext } = await extractFileInfoOrThrow({ file, filename });
|
||||
|
||||
const fileId = v4();
|
||||
const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
@@ -83,9 +81,8 @@ export class FileCorePictureService {
|
||||
(await this.findCustomApplicationUniversalIdentifier(workspaceId));
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: finalName,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
+3
-7
@@ -8,8 +8,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileEmailAttachmentService {
|
||||
@@ -30,13 +29,11 @@ export class FileEmailAttachmentService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -48,9 +45,8 @@ export class FileEmailAttachmentService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
+3
-7
@@ -8,8 +8,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileWorkflowService {
|
||||
@@ -28,13 +27,11 @@ export class FileWorkflowService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -46,9 +43,8 @@ export class FileWorkflowService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.Workflow,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
+3
-7
@@ -15,8 +15,7 @@ import {
|
||||
FilesFieldException,
|
||||
FilesFieldExceptionCode,
|
||||
} from 'src/engine/core-modules/file/files-field/files-field.exception';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -53,13 +52,11 @@ export class FilesFieldService {
|
||||
);
|
||||
}
|
||||
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -82,9 +79,8 @@ export class FilesFieldService {
|
||||
});
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: `${fieldMetadata.universalIdentifier}/${name}`,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
+25
-5
@@ -1,4 +1,4 @@
|
||||
import { extractFileInfo } from '../extract-file-info.utils';
|
||||
import { extractFileInfoOrThrow } from '../extract-file-info-or-throw.utils';
|
||||
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
@@ -8,7 +8,7 @@ const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
describe('extractFileInfo', () => {
|
||||
describe('extractFileInfoOrThrow', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'PNG',
|
||||
@@ -41,7 +41,7 @@ describe('extractFileInfo', () => {
|
||||
])(
|
||||
'should detect $name from buffer magic numbers',
|
||||
async ({ buffer, filename, ext, mime }) => {
|
||||
const result = await extractFileInfo({ file: buffer, filename });
|
||||
const result = await extractFileInfoOrThrow({ file: buffer, filename });
|
||||
|
||||
expect(result).toEqual({ mimeType: mime, ext });
|
||||
},
|
||||
@@ -78,7 +78,10 @@ describe('extractFileInfo', () => {
|
||||
])(
|
||||
'should fall back to extension for $name files',
|
||||
async ({ filename, ext, mime }) => {
|
||||
const result = await extractFileInfo({ file: textBuffer, filename });
|
||||
const result = await extractFileInfoOrThrow({
|
||||
file: textBuffer,
|
||||
filename,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ mimeType: mime, ext });
|
||||
},
|
||||
@@ -95,10 +98,27 @@ describe('extractFileInfo', () => {
|
||||
'should throw when $ext extension does not match buffer content',
|
||||
async ({ filename, ext, expectedMime }) => {
|
||||
await expect(
|
||||
extractFileInfo({ file: textBuffer, filename }),
|
||||
extractFileInfoOrThrow({ file: textBuffer, filename }),
|
||||
).rejects.toThrow(
|
||||
`File content does not match its extension. The file has extension '${ext}' (expected mime type: ${expectedMime}), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe('TWENTY_MIME_POLICY (Twenty deviates from IANA)', () => {
|
||||
it.each([
|
||||
{ filename: 'src/index.ts', expectedMime: 'application/typescript' },
|
||||
{ filename: 'src/index.tsx', expectedMime: 'application/typescript' },
|
||||
])(
|
||||
'should return $expectedMime for $filename without throwing on IANA collision',
|
||||
async ({ filename, expectedMime }) => {
|
||||
const result = await extractFileInfoOrThrow({
|
||||
file: textBuffer,
|
||||
filename,
|
||||
});
|
||||
|
||||
expect(result.mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+23
-6
@@ -10,9 +10,14 @@ import {
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
|
||||
import { detectPdf } from '@file-type/pdf';
|
||||
import { TWENTY_MIME_POLICY } from 'src/engine/core-modules/file/constants/twenty-mime-policy.constant';
|
||||
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
|
||||
|
||||
export const extractFileInfo = async ({
|
||||
const fileTypeParser = new FileTypeParser({
|
||||
customDetectors: [detectPdf],
|
||||
});
|
||||
|
||||
export const extractFileInfoOrThrow = async ({
|
||||
file,
|
||||
filename,
|
||||
}: {
|
||||
@@ -21,12 +26,8 @@ export const extractFileInfo = async ({
|
||||
}) => {
|
||||
const { ext: declaredExt } = buildFileInfo(filename);
|
||||
|
||||
const fileParser = new FileTypeParser({
|
||||
customDetectors: [detectPdf],
|
||||
});
|
||||
|
||||
const { ext: detectedExt, mime: detectedMime } =
|
||||
(await fileParser.fromBuffer(file)) ?? {};
|
||||
(await fileTypeParser.fromBuffer(file)) ?? {};
|
||||
|
||||
if (isDefined(detectedExt) && isDefined(detectedMime)) {
|
||||
return {
|
||||
@@ -40,6 +41,22 @@ export const extractFileInfo = async ({
|
||||
let mimeType: string = 'application/octet-stream';
|
||||
|
||||
if (isNonEmptyString(ext)) {
|
||||
// Twenty policy wins over the ext-based fallback for the (small) set of
|
||||
// extensions where mrmime's IANA mapping collides with a developer-tooling
|
||||
// convention. This branch is only reached when file-type's magic-byte
|
||||
// sniff returned nothing — when the bytes actually match (e.g. a real
|
||||
// MPEG-TS video at foo.ts), we already returned above.
|
||||
//
|
||||
// For .ts/.tsx the policy is also load-bearing for correctness: without
|
||||
// it, lookup('ts') → 'video/mp2t' is in file-type's supportedMimeTypes,
|
||||
// so the check below would throw INVALID_EXTENSION on every TypeScript
|
||||
// source upload.
|
||||
const policyMime = TWENTY_MIME_POLICY[ext];
|
||||
|
||||
if (isDefined(policyMime)) {
|
||||
return { mimeType: policyMime, ext };
|
||||
}
|
||||
|
||||
const mimeTypeFromExtension = lookup(ext);
|
||||
|
||||
if (
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Die veld waarvoor jy toestemmings probeer stel, kon nie gevind word nie. Dit mag verwyder gewees het."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Die lêeruitbreiding stem nie ooreen met die lêerinhoud nie. Maak asseblief seker dat jou lêer nie beskadig is nie en die korrekte lêeruitbreiding het."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "لم يتم العثور على الحقل الذي تحاول تعيين الأذونات له. ربما تم حذفه."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "امتداد الملف لا يتطابق مع محتوى الملف. يرجى التحقق من أن ملفك غير تالف وأن لديه الامتداد الصحيح."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "El camp pels permisos del qual estàs intentant establir no s'ha pogut trobar. Pot haver estat eliminat."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "L'extensió del fitxer no coincideix amb el contingut del fitxer. Comproveu que el fitxer no estigui malmès i que tingui l'extensió correcta."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Pole, pro které se pokoušíte nastavit oprávnění, nebylo nalezeno. Může být smazáno."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Přípona souboru neodpovídá obsahu souboru. Zkontrolujte, zda soubor není poškozený a má správnou příponu."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Feltet, du forsøger at indstille tilladelser for, kunne ikke findes. Det kan være blevet slettet."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Filendelsen stemmer ikke overens med filens indhold. Kontroller venligst, at din fil ikke er beskadiget, og at den har den korrekte filendelse."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Das Feld, für das Sie Berechtigungen setzen möchten, konnte nicht gefunden werden. Möglicherweise wurde es gelöscht."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Die Dateierweiterung stimmt nicht mit dem Dateiinhalt überein. Bitte prüfen Sie, ob Ihre Datei nicht beschädigt ist und die korrekte Dateierweiterung hat."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Το πεδίο για το οποίο προσπαθείτε να ορίσετε άδειες δεν μπορεί να βρεθεί. Μπορεί να έχει διαγραφεί."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Η επέκταση του αρχείου δεν αντιστοιχεί στο περιεχόμενο του αρχείου. Παρακαλούμε ελέγξτε ότι το αρχείο σας δεν είναι κατεστραμμένο και έχει τη σωστή επέκταση."
|
||||
|
||||
|
||||
@@ -6754,7 +6754,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "The field you are trying to set permissions for could not be found. It may have been deleted."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "El campo para el que está intentando establecer permisos no se pudo encontrar. Es posible que haya sido eliminado."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "La extensión del archivo no coincide con el contenido del archivo. Compruebe que su archivo no esté dañado y que tenga la extensión correcta."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Kenttää, jolle yrität asettaa oikeuksia, ei löytynyt. Se saatettiin poistaa."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Tiedostopääte ei vastaa tiedoston sisältöä. Tarkista, ettei tiedosto ole vioittunut ja että sillä on oikea tiedostopääte."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Le champ pour lequel vous essayez de définir des permissions n'a pas pu être trouvé. Il a peut-être été supprimé."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "L'extension du fichier ne correspond pas au contenu du fichier. Veuillez vérifier que votre fichier n'est pas corrompu et qu'il a l'extension correcte."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "לא ניתן למצוא את השדה שאתה מנסה להגדיר הרשאות עבורו. ייתכן שהוא נמחק."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "סיומת הקובץ אינה תואמת את תוכן הקובץ. יש לבדוק שהקובץ אינו פגום ושיש לו את הסיומת הנכונה."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "A mező, amelyre engedélyeket próbál beállítani, nem található. Lehet, hogy törölték."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "A fájlkiterjesztés nem egyezik a fájl tartalmával. Kérjük, ellenőrizze, hogy a fájl nem sérült, és a kiterjesztése helyes."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Il campo per il quale si tenta di impostare i permessi non è stato trovato. Potrebbe essere stato eliminato."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "L'estensione del file non corrisponde al contenuto del file. Verifica che il tuo file non sia danneggiato e che abbia l'estensione corretta."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "権限を設定しようとしているフィールドが見つかりません。それは削除された可能性があります。"
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "ファイルの拡張子がファイルの内容と一致しません。ファイルが破損しておらず、正しい拡張子であることを確認してください。"
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "설정하려는 필드를 찾을 수 없습니다. 삭제되었을 수 있습니다."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "파일 확장자가 파일 내용과 일치하지 않습니다. 파일이 손상되지 않았고 올바른 확장자를 사용하고 있는지 확인하세요."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Het veld waarvoor u rechten probeert in te stellen kon niet worden gevonden. Het is mogelijk verwijderd."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "De bestandsextensie komt niet overeen met de inhoud van het bestand. Controleer of je bestand niet beschadigd is en de juiste extensie heeft."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Feltet du prøver å sette tillatelser for, kunne ikke bli funnet. Det kan ha blitt slettet."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Filendelsen samsvarer ikke med filinnholdet. Kontroller at filen ikke er skadet og at den har riktig filendelse."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Nie można znaleźć pola, dla którego próbujesz ustawić uprawnienia. Mogło zostać usunięte."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Rozszerzenie pliku nie odpowiada zawartości pliku. Sprawdź, czy plik nie jest uszkodzony i ma prawidłowe rozszerzenie."
|
||||
|
||||
|
||||
@@ -6754,7 +6754,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "O campo para o qual você está tentando definir permissões não pôde ser encontrado. Ele pode ter sido excluído."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "A extensão do arquivo não corresponde ao conteúdo do arquivo. Verifique se o seu arquivo não está corrompido e se tem a extensão correta."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "O campo para o qual você está tentando definir permissões não pôde ser encontrado. Pode ter sido excluído."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "A extensão do arquivo não corresponde ao conteúdo do arquivo. Verifique se o arquivo não está corrompido e tem a extensão correta."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Câmpul pentru care încercați să setați permisiuni nu a putut fi găsit. Este posibil să fi fost șters."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Extensia fișierului nu corespunde conținutului fișierului. Vă rugăm să verificați că fișierul nu este corupt și are extensia corectă."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Поле, для которого вы пытаетесь установить разрешения, не найдено. Возможно, оно было удалено."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Расширение файла не соответствует содержимому файла. Проверьте, что файл не поврежден и имеет правильное расширение."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Поље за које покушавате да подесите дозволе није пронађено. Могуће је да је избрисано."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Екстензија датотеке се не поклапа са садржајем датотеке. Проверите да ли ваша датотека није оштећена и да ли има исправну екстензију."
|
||||
|
||||
|
||||
@@ -6765,7 +6765,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Fältet du försöker ställa in behörigheter för kunde inte hittas. Det kan ha blivit raderat."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Filändelsen stämmer inte överens med filens innehåll. Kontrollera att filen inte är skadad och att den har rätt filändelse."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "İzin ayarlamaya çalıştığınız alan bulunamadı. Silinmiş olabilir."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Dosya uzantısı dosya içeriğiyle eşleşmiyor. Lütfen dosyanızın bozuk olmadığını ve doğru uzantıya sahip olduğunu kontrol edin."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Поле, для якого ви намагаєтеся встановити дозволи, не знайдено. Воно могло бути видалено."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Розширення файлу не відповідає вмісту файлу. Перевірте, чи ваш файл не пошкоджений і має правильне розширення."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "Không thể tìm thấy trường bạn đang cố gắng thiết lập quyền hạn. Nó có thể đã bị xóa."
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "Phần mở rộng tệp không khớp với nội dung tệp. Vui lòng kiểm tra để đảm bảo tệp của bạn không bị hỏng và có phần mở rộng chính xác."
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "您尝试设置权限的字段未找到。它可能已被删除。"
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "文件扩展名与文件内容不匹配。请检查您的文件未损坏且具有正确的扩展名。"
|
||||
|
||||
|
||||
@@ -6759,7 +6759,7 @@ msgid "The field you are trying to set permissions for could not be found. It ma
|
||||
msgstr "您嘗試設置權限的字段找不到。它可能已被刪除。"
|
||||
|
||||
#. js-lingui-id: bAGU1r
|
||||
#: src/engine/core-modules/file/utils/extract-file-info.utils.ts
|
||||
#: src/engine/core-modules/file/utils/extract-file-info-or-throw.utils.ts
|
||||
msgid "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension."
|
||||
msgstr "檔案副檔名與檔案內容不相符。請確認您的檔案未損毀,且具有正確的副檔名。"
|
||||
|
||||
|
||||
-4
@@ -94,7 +94,6 @@ export class LogicFunctionResourceService {
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: sourceHandlerPath,
|
||||
sourceFile: sourceFile.content,
|
||||
mimeType: 'application/typescript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
@@ -107,7 +106,6 @@ export class LogicFunctionResourceService {
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
resourcePath: builtHandlerPath,
|
||||
sourceFile: builtFile.content,
|
||||
mimeType: 'application/javascript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
@@ -139,7 +137,6 @@ export class LogicFunctionResourceService {
|
||||
resourcePath: sourceHandlerPath,
|
||||
sourceFile: sourceHandlerCode,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
mimeType: 'application/typescript',
|
||||
queryRunner,
|
||||
});
|
||||
}
|
||||
@@ -172,7 +169,6 @@ export class LogicFunctionResourceService {
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
resourcePath: builtHandlerPath,
|
||||
sourceFile: builtCode,
|
||||
mimeType: 'application/javascript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
|
||||
-1
@@ -157,7 +157,6 @@ export class SdkClientGenerationService {
|
||||
fileFolder: FileFolder.GeneratedSdkClient,
|
||||
resourcePath: SDK_CLIENT_ARCHIVE_NAME,
|
||||
sourceFile: archiveBuffer,
|
||||
mimeType: 'application/zip',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
|
||||
-1
@@ -337,7 +337,6 @@ export class CodeInterpreterTool implements Tool {
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: file.content,
|
||||
mimeType: file.mimeType,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
@@ -42,9 +42,13 @@ import {
|
||||
ConfigVariableException,
|
||||
ConfigVariableExceptionCode,
|
||||
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
|
||||
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
|
||||
import { type AiProvidersConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-providers-config.type';
|
||||
import { DEFAULT_MODEL_PREFERENCES } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util';
|
||||
import {
|
||||
DEFAULT_DISABLED_MODELS,
|
||||
DEFAULT_FAST_MODELS,
|
||||
DEFAULT_RECOMMENDED_MODELS,
|
||||
DEFAULT_SMART_MODELS,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-model-preferences.util';
|
||||
|
||||
export class ConfigVariables {
|
||||
@ConfigVariablesMetadata({
|
||||
@@ -1435,20 +1439,38 @@ export class ConfigVariables {
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
description:
|
||||
'AI model admin preferences: disabled models, recommended models, and default fast/smart model lists. Managed via admin panel or env.',
|
||||
type: ConfigVariableType.JSON,
|
||||
'Ordered list of fast model IDs to use as defaults. Managed via admin panel or env.',
|
||||
type: ConfigVariableType.ARRAY,
|
||||
})
|
||||
@IsOptional()
|
||||
AI_MODEL_PREFERENCES: AiModelPreferences = DEFAULT_MODEL_PREFERENCES;
|
||||
AI_MODELS_DEFAULT_FAST: string[] = DEFAULT_FAST_MODELS;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
description:
|
||||
'Storage path for AI model preferences fallback (e.g. config/ai-model-preferences.json). Loaded at startup and used only when no value is set via AI_MODEL_PREFERENCES env var or the database.',
|
||||
type: ConfigVariableType.STRING,
|
||||
'Ordered list of smart model IDs to use as defaults. Managed via admin panel or env.',
|
||||
type: ConfigVariableType.ARRAY,
|
||||
})
|
||||
@IsOptional()
|
||||
AI_MODEL_PREFERENCES_STORAGE_PATH?: string;
|
||||
AI_MODELS_DEFAULT_SMART: string[] = DEFAULT_SMART_MODELS;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
description:
|
||||
'List of recommended model IDs shown to workspaces using curated model selection. Managed via admin panel or env.',
|
||||
type: ConfigVariableType.ARRAY,
|
||||
})
|
||||
@IsOptional()
|
||||
AI_MODELS_DEFAULT_RECOMMENDED: string[] = DEFAULT_RECOMMENDED_MODELS;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.LLM,
|
||||
description:
|
||||
'List of model IDs disabled by default. Disabled models cannot be used by any workspace. Managed via admin panel or env.',
|
||||
type: ConfigVariableType.ARRAY,
|
||||
})
|
||||
@IsOptional()
|
||||
AI_MODELS_DEFAULT_DISABLED: string[] = DEFAULT_DISABLED_MODELS;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
import { MeterDriver } from 'src/engine/core-modules/metrics/types/meter-driver.type';
|
||||
@@ -6,7 +7,7 @@ export const CastToMeterDriverArray = () =>
|
||||
Transform(({ value }: { value: string }) => toMeterDriverArray(value));
|
||||
|
||||
const toMeterDriverArray = (value: string | undefined) => {
|
||||
if (typeof value === 'string') {
|
||||
if (isNonEmptyString(value)) {
|
||||
const rawMeterDrivers = value.split(',').map((driver) => driver.trim());
|
||||
const isInvalid = rawMeterDrivers.some(
|
||||
(driver) => !Object.values(MeterDriver).includes(driver as MeterDriver),
|
||||
|
||||
+74
@@ -1,4 +1,9 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
|
||||
import { ConfigVariablesMetadata } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator';
|
||||
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
|
||||
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
|
||||
import { typeTransformers } from 'src/engine/core-modules/twenty-config/utils/type-transformers.registry';
|
||||
|
||||
describe('Type Transformers Registry', () => {
|
||||
@@ -141,6 +146,75 @@ describe('Type Transformers Registry', () => {
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('class-transformer transformer (env-loading path)', () => {
|
||||
class TestArrayConfig {
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.ADVANCED_SETTINGS,
|
||||
description: 'Test array',
|
||||
type: ConfigVariableType.ARRAY,
|
||||
})
|
||||
ARRAY_VALUE: string[] = [];
|
||||
}
|
||||
|
||||
const transformAndValidate = (raw: unknown) => {
|
||||
const instance = plainToInstance(TestArrayConfig, {
|
||||
ARRAY_VALUE: raw,
|
||||
});
|
||||
const errors = validateSync(instance, { strictGroups: true });
|
||||
|
||||
return { instance, errors };
|
||||
};
|
||||
|
||||
it('should parse comma-separated string into array', () => {
|
||||
const { instance, errors } = transformAndValidate('a,b,c');
|
||||
|
||||
expect(instance.ARRAY_VALUE).toEqual(['a', 'b', 'c']);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should parse JSON-encoded array string', () => {
|
||||
const { instance, errors } = transformAndValidate('["a","b","c"]');
|
||||
|
||||
expect(instance.ARRAY_VALUE).toEqual(['a', 'b', 'c']);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should parse empty JSON array string', () => {
|
||||
const { instance, errors } = transformAndValidate('[]');
|
||||
|
||||
expect(instance.ARRAY_VALUE).toEqual([]);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should wrap a single value in an array', () => {
|
||||
const { instance, errors } = transformAndValidate('openai/gpt-4.1');
|
||||
|
||||
expect(instance.ARRAY_VALUE).toEqual(['openai/gpt-4.1']);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should trim whitespace around comma-separated items', () => {
|
||||
const { instance, errors } = transformAndValidate(' a , b , c ');
|
||||
|
||||
expect(instance.ARRAY_VALUE).toEqual(['a', 'b', 'c']);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should drop empty entries from comma-separated input', () => {
|
||||
const { instance, errors } = transformAndValidate('a,,b,');
|
||||
|
||||
expect(instance.ARRAY_VALUE).toEqual(['a', 'b']);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should keep already-array values unchanged', () => {
|
||||
const { instance, errors } = transformAndValidate(['a', 'b']);
|
||||
|
||||
expect(instance.ARRAY_VALUE).toEqual(['a', 'b']);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enum Transformer', () => {
|
||||
|
||||
+18
-1
@@ -2,9 +2,11 @@ import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
isDefined,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
isString,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
|
||||
import { type ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
|
||||
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
|
||||
import { tryParseJsonArray } from 'src/utils/try-parse-json-array';
|
||||
|
||||
export interface TypeTransformer<T> {
|
||||
toApp: (value: unknown, options?: ConfigVariableOptions) => T | undefined;
|
||||
@@ -193,7 +196,21 @@ export const typeTransformers: Record<
|
||||
|
||||
getValidators: (): PropertyDecorator[] => [IsArray()],
|
||||
|
||||
getTransformers: (): PropertyDecorator[] => [],
|
||||
getTransformers: (): PropertyDecorator[] => [
|
||||
Transform(({ value }) => {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (!isString(value)) return value;
|
||||
|
||||
const fromJson = tryParseJsonArray(value);
|
||||
|
||||
if (isDefined(fromJson)) return fromJson;
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
[ConfigVariableType.ENUM]: {
|
||||
|
||||
+1
-1
@@ -7,4 +7,4 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export const TWENTY_CURRENT_VERSION = '2.8.0' as const;
|
||||
export const TWENTY_CURRENT_VERSION = '2.9.0' as const;
|
||||
|
||||
+3
-1
@@ -7,4 +7,6 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export const TWENTY_NEXT_VERSIONS = ['2.9.0'] as const;
|
||||
export const TWENTY_NEXT_VERSIONS = [
|
||||
'2.10.0',
|
||||
] as const;
|
||||
|
||||
+1
@@ -19,4 +19,5 @@ export const TWENTY_PREVIOUS_VERSIONS = [
|
||||
'2.5.0',
|
||||
'2.6.0',
|
||||
'2.7.0',
|
||||
'2.8.0',
|
||||
] as const;
|
||||
|
||||
@@ -55,7 +55,10 @@ describe('UserService', () => {
|
||||
},
|
||||
{
|
||||
provide: WorkspaceService,
|
||||
useValue: { deleteWorkspace: jest.fn() },
|
||||
useValue: {
|
||||
deleteWorkspace: jest.fn(),
|
||||
suspendWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceDomainsService,
|
||||
@@ -386,7 +389,8 @@ describe('UserService', () => {
|
||||
|
||||
const res = await service.deleteUser('u2');
|
||||
|
||||
expect(workspaceService.deleteWorkspace).toHaveBeenCalledWith('w2');
|
||||
expect(workspaceService.suspendWorkspace).toHaveBeenCalledWith('w2');
|
||||
expect(workspaceService.deleteWorkspace).toHaveBeenCalledWith('w2', true);
|
||||
expect(res).toMatchObject({ id: 'u2' });
|
||||
});
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
|
||||
import { type QueryRunner, In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { EmailVerificationTrigger } from 'src/engine/core-modules/email-verification/email-verification.constants';
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
@@ -27,7 +29,7 @@ import {
|
||||
UpdateWorkspaceMemberEmailJob,
|
||||
UpdateWorkspaceMemberEmailJobData,
|
||||
} from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job';
|
||||
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserExceptionCode } from 'src/engine/core-modules/user/user.exception';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
@@ -39,11 +41,9 @@ import {
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
|
||||
|
||||
// oxlint-disable-next-line twenty/inject-workspace-repository
|
||||
export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
@@ -322,7 +322,8 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
const userWorkspaceId = userWorkspace.id;
|
||||
|
||||
if (workspaceMembers.length === 1) {
|
||||
await this.workspaceService.deleteWorkspace(workspaceId);
|
||||
await this.workspaceService.suspendWorkspace(workspaceId);
|
||||
await this.workspaceService.deleteWorkspace(workspaceId, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
"modelFamily": "GPT",
|
||||
"inputCostPerMillionTokens": 1.25,
|
||||
"outputCostPerMillionTokens": 10,
|
||||
"cachedInputCostPerMillionTokens": 0.13,
|
||||
"cachedInputCostPerMillionTokens": 0.125,
|
||||
"contextWindowTokens": 400000,
|
||||
"maxOutputTokens": 128000,
|
||||
"modalities": ["image"],
|
||||
@@ -216,7 +216,7 @@
|
||||
"modelFamily": "GPT",
|
||||
"inputCostPerMillionTokens": 0.15,
|
||||
"outputCostPerMillionTokens": 0.6,
|
||||
"cachedInputCostPerMillionTokens": 0.08,
|
||||
"cachedInputCostPerMillionTokens": 0.075,
|
||||
"contextWindowTokens": 128000,
|
||||
"maxOutputTokens": 16384,
|
||||
"modalities": ["image", "pdf"]
|
||||
@@ -342,7 +342,7 @@
|
||||
"modelFamily": "GPT",
|
||||
"inputCostPerMillionTokens": 0.1,
|
||||
"outputCostPerMillionTokens": 0.4,
|
||||
"cachedInputCostPerMillionTokens": 0.03,
|
||||
"cachedInputCostPerMillionTokens": 0.025,
|
||||
"contextWindowTokens": 1047576,
|
||||
"maxOutputTokens": 32768,
|
||||
"modalities": ["image"]
|
||||
@@ -362,7 +362,7 @@
|
||||
"modelFamily": "GPT",
|
||||
"inputCostPerMillionTokens": 1.1,
|
||||
"outputCostPerMillionTokens": 4.4,
|
||||
"cachedInputCostPerMillionTokens": 0.28,
|
||||
"cachedInputCostPerMillionTokens": 0.275,
|
||||
"contextWindowTokens": 200000,
|
||||
"maxOutputTokens": 100000,
|
||||
"modalities": ["image"],
|
||||
|
||||
+33
-59
@@ -1,59 +1,26 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { aiModelPreferencesSchema } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.schema';
|
||||
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
|
||||
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@Injectable()
|
||||
export class AiModelPreferencesService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiModelPreferencesService.name);
|
||||
private filePreferences: AiModelPreferences | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const storagePath = this.twentyConfigService.get(
|
||||
'AI_MODEL_PREFERENCES_STORAGE_PATH',
|
||||
);
|
||||
|
||||
if (!storagePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.filePreferences = await this.fetchPreferences(storagePath);
|
||||
this.logger.log(
|
||||
`AI_MODEL_PREF - Loaded AI model preferences from storage: ${storagePath}`,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`AI_MODEL_PREF - Failed to load AI model preferences from storage: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
export class AiModelPreferencesService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
getPreferences(): AiModelPreferences {
|
||||
const { source } =
|
||||
this.twentyConfigService.getVariableWithMetadata(
|
||||
'AI_MODEL_PREFERENCES',
|
||||
) ?? {};
|
||||
|
||||
if (source !== ConfigSource.DEFAULT) {
|
||||
return this.twentyConfigService.get('AI_MODEL_PREFERENCES');
|
||||
}
|
||||
|
||||
return (
|
||||
this.filePreferences ??
|
||||
this.twentyConfigService.get('AI_MODEL_PREFERENCES')
|
||||
);
|
||||
return {
|
||||
defaultFastModels: this.twentyConfigService.get('AI_MODELS_DEFAULT_FAST'),
|
||||
defaultSmartModels: this.twentyConfigService.get(
|
||||
'AI_MODELS_DEFAULT_SMART',
|
||||
),
|
||||
recommendedModels: this.twentyConfigService.get(
|
||||
'AI_MODELS_DEFAULT_RECOMMENDED',
|
||||
),
|
||||
disabledModels: this.twentyConfigService.get(
|
||||
'AI_MODELS_DEFAULT_DISABLED',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
getRecommendedModelIds(): Set<string> {
|
||||
@@ -130,16 +97,23 @@ export class AiModelPreferencesService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async persistPreferences(prefs: AiModelPreferences): Promise<void> {
|
||||
await this.twentyConfigService.set('AI_MODEL_PREFERENCES', prefs);
|
||||
}
|
||||
|
||||
private async fetchPreferences(
|
||||
filePath: string,
|
||||
): Promise<AiModelPreferences> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const stream = await driver.readFile({ filePath });
|
||||
const body = (await streamToBuffer(stream)).toString('utf-8');
|
||||
|
||||
return aiModelPreferencesSchema.parse(JSON.parse(body));
|
||||
await Promise.all([
|
||||
this.twentyConfigService.set(
|
||||
'AI_MODELS_DEFAULT_FAST',
|
||||
prefs.defaultFastModels ?? [],
|
||||
),
|
||||
this.twentyConfigService.set(
|
||||
'AI_MODELS_DEFAULT_SMART',
|
||||
prefs.defaultSmartModels ?? [],
|
||||
),
|
||||
this.twentyConfigService.set(
|
||||
'AI_MODELS_DEFAULT_RECOMMENDED',
|
||||
prefs.recommendedModels ?? [],
|
||||
),
|
||||
this.twentyConfigService.set(
|
||||
'AI_MODELS_DEFAULT_DISABLED',
|
||||
prefs.disabledModels ?? [],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -2,7 +2,7 @@
|
||||
// instead of hardcoding model IDs that become stale as models evolve
|
||||
import { type AiModelPreferences } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-preferences.type';
|
||||
|
||||
const DEFAULT_FAST_MODELS = [
|
||||
export const DEFAULT_FAST_MODELS = [
|
||||
'openai/gpt-5-mini',
|
||||
'anthropic/claude-haiku-4-5-20251001',
|
||||
'google/gemini-3-flash-preview',
|
||||
@@ -10,7 +10,7 @@ const DEFAULT_FAST_MODELS = [
|
||||
'mistral/mistral-large-latest',
|
||||
];
|
||||
|
||||
const DEFAULT_SMART_MODELS = [
|
||||
export const DEFAULT_SMART_MODELS = [
|
||||
'openai/gpt-5.2',
|
||||
'anthropic/claude-sonnet-4-6',
|
||||
'google/gemini-3.1-pro-preview',
|
||||
@@ -18,7 +18,7 @@ const DEFAULT_SMART_MODELS = [
|
||||
'mistral/mistral-large-latest',
|
||||
];
|
||||
|
||||
const DEFAULT_RECOMMENDED_MODELS = [
|
||||
export const DEFAULT_RECOMMENDED_MODELS = [
|
||||
'openai/gpt-5.2',
|
||||
'openai/gpt-4.1',
|
||||
'anthropic/claude-opus-4-6',
|
||||
@@ -27,6 +27,8 @@ const DEFAULT_RECOMMENDED_MODELS = [
|
||||
'xai/grok-4',
|
||||
];
|
||||
|
||||
export const DEFAULT_DISABLED_MODELS: string[] = [];
|
||||
|
||||
export const DEFAULT_MODEL_PREFERENCES: AiModelPreferences = {
|
||||
disabledModels: [],
|
||||
recommendedModels: DEFAULT_RECOMMENDED_MODELS,
|
||||
|
||||
-1
@@ -465,7 +465,6 @@ export class DevSeederDataService {
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile,
|
||||
mimeType: metadata.mimeType,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
-2
@@ -83,7 +83,6 @@ export class PrefillFrontComponentService {
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: sourceComponentPath,
|
||||
sourceFile: sourceFile.content,
|
||||
mimeType: 'application/typescript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
@@ -97,7 +96,6 @@ export class PrefillFrontComponentService {
|
||||
fileFolder: FileFolder.BuiltFrontComponent,
|
||||
resourcePath: builtComponentPath,
|
||||
sourceFile: builtFile.content,
|
||||
mimeType: 'application/javascript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const tryParseJsonArray = (value: string): unknown[] | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
+7
-12
@@ -131,7 +131,7 @@ describe('Successful user and workspace creation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should delete workspace and related metadata entities when last user is deleted', async () => {
|
||||
it('should suspend and soft-delete workspace when last user is deleted', async () => {
|
||||
const uniqueEmail = `test-delete-${randomUUID()}@example.com`;
|
||||
|
||||
const { data } = await signUp({
|
||||
@@ -253,16 +253,11 @@ describe('Successful user and workspace creation', () => {
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
expect(workspaceAfterDeletion).toHaveLength(0);
|
||||
|
||||
for (const table of tablesToVerify) {
|
||||
const result = await testDataSource.query(
|
||||
`SELECT COUNT(*) as count FROM core."${table}" WHERE "workspaceId" = $1`,
|
||||
[workspaceId],
|
||||
);
|
||||
const count = parseInt(result[0].count);
|
||||
|
||||
expect({ count, table }).toEqual({ count: 0, table });
|
||||
}
|
||||
expect(workspaceAfterDeletion).toHaveLength(1);
|
||||
expect(workspaceAfterDeletion[0].activationStatus).toBe(
|
||||
WorkspaceActivationStatus.SUSPENDED,
|
||||
);
|
||||
expect(workspaceAfterDeletion[0].suspendedAt).not.toBeNull();
|
||||
expect(workspaceAfterDeletion[0].deletedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .pdf path is uploaded with plain text body (magic-byte mismatch) 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "INVALID_EXTENSION",
|
||||
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
|
||||
},
|
||||
"message": "File content does not match its extension. The file has extension 'pdf' (expected mime type: application/pdf), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .png path is uploaded with plain text body (magic-byte mismatch) 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "INVALID_EXTENSION",
|
||||
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
|
||||
},
|
||||
"message": "File content does not match its extension. The file has extension 'png' (expected mime type: image/png), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .zip path is uploaded with plain text body (magic-byte mismatch) 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "INVALID_EXTENSION",
|
||||
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
|
||||
},
|
||||
"message": "File content does not match its extension. The file has extension 'zip' (expected mime type: application/zip), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
+3
-6
@@ -16,6 +16,7 @@ const UNKNOWN_APPLICATION_ID = uuidv4();
|
||||
|
||||
const PUBLIC_ASSET_PATH = 'assets/logo.svg';
|
||||
const PUBLIC_ASSET_CONTENT = '<svg><circle r="10" /></svg>';
|
||||
const PERSISTED_PUBLIC_ASSET_CONTENT = '<svg><circle r="10"></circle></svg>';
|
||||
const PUBLIC_ASSET_CONTENT_TYPE = 'image/svg+xml';
|
||||
|
||||
type FailingCase = {
|
||||
@@ -81,8 +82,7 @@ describe('Public assets controller download should fail', () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_UID,
|
||||
name: 'Test Public Assets Download Failure App',
|
||||
description:
|
||||
'App for testing failing public-assets controller downloads',
|
||||
description: 'App for testing failing public-assets controller downloads',
|
||||
sourcePath: 'test-public-assets-download-failure',
|
||||
});
|
||||
|
||||
@@ -93,9 +93,6 @@ describe('Public assets controller download should fail', () => {
|
||||
|
||||
applicationId = id;
|
||||
|
||||
// A real public asset must exist so the "no content leak" guard below is
|
||||
// meaningful — without it, `not.toContain(PUBLIC_ASSET_CONTENT)` would
|
||||
// pass trivially regardless of the controller's behavior.
|
||||
jest.useRealTimers();
|
||||
|
||||
await uploadApplicationFile({
|
||||
@@ -129,7 +126,7 @@ describe('Public assets controller download should fail', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// The legitimate asset content must never leak through a failure path.
|
||||
expect(response.text).not.toContain(PUBLIC_ASSET_CONTENT);
|
||||
expect(response.text).not.toContain(PERSISTED_PUBLIC_ASSET_CONTENT);
|
||||
|
||||
expectOneNotInternalServerErrorHttpResponseSnapshot({
|
||||
status: response.status,
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
||||
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
|
||||
import {
|
||||
type EachTestingContext,
|
||||
eachTestingContextFilter,
|
||||
} from 'twenty-shared/testing';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
|
||||
const PNG_BUFFER = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52,
|
||||
]);
|
||||
const TEXT_BUFFER = Buffer.from('not actually binary content', 'utf-8');
|
||||
|
||||
type TestContext = {
|
||||
applicationUniversalIdentifier: string;
|
||||
fileFolder: string;
|
||||
filePath: string;
|
||||
fileBuffer: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
|
||||
{
|
||||
title:
|
||||
'when a PublicAsset .png path is uploaded with plain text body (magic-byte mismatch)',
|
||||
context: {
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'assets/fake-image.png',
|
||||
fileBuffer: TEXT_BUFFER,
|
||||
filename: 'fake-image.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'when a PublicAsset .pdf path is uploaded with plain text body (magic-byte mismatch)',
|
||||
context: {
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'docs/fake.pdf',
|
||||
fileBuffer: TEXT_BUFFER,
|
||||
filename: 'fake.pdf',
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'when a PublicAsset .zip path is uploaded with plain text body (magic-byte mismatch)',
|
||||
context: {
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'archives/fake.zip',
|
||||
fileBuffer: TEXT_BUFFER,
|
||||
filename: 'fake.zip',
|
||||
contentType: 'application/zip',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('Upload application file should fail on mime/magic-byte mismatch', () => {
|
||||
beforeAll(async () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
name: 'Test Upload Mime Mismatch App',
|
||||
description: 'App for testing magic-byte vs extension validation',
|
||||
sourcePath: 'test-upload-mime-mismatch',
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
|
||||
'$title',
|
||||
async ({ context }) => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const { errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: context.applicationUniversalIdentifier,
|
||||
fileFolder: context.fileFolder,
|
||||
filePath: context.filePath,
|
||||
fileBuffer: context.fileBuffer,
|
||||
filename: context.filename,
|
||||
contentType: context.contentType,
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
it('should succeed but persist server-derived mime when uploader-controlled mime is misleading', async () => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const filePath = 'assets/legit-image.png';
|
||||
|
||||
const { data, errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath,
|
||||
fileBuffer: PNG_BUFFER,
|
||||
filename: 'legit-image.png',
|
||||
// Misleading multipart mime — server should ignore it and persist image/png.
|
||||
contentType: 'text/html',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.uploadApplicationFile).toEqual({
|
||||
id: expect.any(String),
|
||||
path: `public-asset/${filePath}`,
|
||||
});
|
||||
|
||||
const [row] = await globalThis.testDataSource.query(
|
||||
`SELECT "mimeType" FROM core."file" WHERE id = $1`,
|
||||
[data.uploadApplicationFile.id],
|
||||
);
|
||||
|
||||
expect(row.mimeType).toBe('image/png');
|
||||
}, 60000);
|
||||
});
|
||||
+3
-1
@@ -9,6 +9,8 @@ const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
|
||||
const PUBLIC_ASSET_PATH = 'assets/logo.svg';
|
||||
const PUBLIC_ASSET_CONTENT = '<svg><circle r="10" /></svg>';
|
||||
const EXPECTED_SANITIZED_PUBLIC_ASSET_CONTENT =
|
||||
'<svg><circle r="10"></circle></svg>';
|
||||
const PUBLIC_ASSET_CONTENT_TYPE = 'image/svg+xml';
|
||||
|
||||
describe('Public assets controller download should succeed', () => {
|
||||
@@ -76,7 +78,7 @@ describe('Public assets controller download should succeed', () => {
|
||||
);
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect((response.body as Buffer).toString('utf-8')).toBe(
|
||||
PUBLIC_ASSET_CONTENT,
|
||||
EXPECTED_SANITIZED_PUBLIC_ASSET_CONTENT,
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
||||
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
const STORAGE_LOCAL_PATH = join(process.cwd(), '.local-storage');
|
||||
|
||||
const MALICIOUS_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" onload="alert(1)">
|
||||
<script>alert('xss')</script>
|
||||
<a xlink:href="javascript:alert('xss-link')">
|
||||
<circle cx="50" cy="50" r="40" />
|
||||
</a>
|
||||
</svg>`;
|
||||
|
||||
// Deterministic DOMPurify output for MALICIOUS_SVG. Shape:
|
||||
// - <script>...</script> stripped (the two leading spaces on that line are
|
||||
// preserved — DOMPurify removes the element, not surrounding whitespace).
|
||||
// - onload="..." attribute stripped from <svg>.
|
||||
// - xlink:href="javascript:..." stripped from <a> (anchor element kept).
|
||||
// - <circle .../> self-closing form normalized to <circle ...></circle>.
|
||||
// Encoded as a single-line literal so whitespace is unambiguous.
|
||||
const EXPECTED_SANITIZED_MALICIOUS_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">\n \n <a>\n <circle cx="50" cy="50" r="40"></circle>\n </a>\n</svg>';
|
||||
|
||||
const BENIGN_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="red" /></svg>';
|
||||
|
||||
const EXPECTED_SANITIZED_BENIGN_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="red"></rect></svg>';
|
||||
|
||||
describe('Upload application file — SVG sanitization (end-to-end)', () => {
|
||||
beforeAll(async () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
name: 'Test SVG Sanitization App',
|
||||
description: 'App for verifying SVG sanitization end-to-end',
|
||||
sourcePath: 'test-svg-sanitization',
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips scripts, event handlers, and javascript: URIs from an uploaded SVG, persisting the exact sanitized content with image/svg+xml mime', async () => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const filePath = 'assets/malicious.svg';
|
||||
|
||||
const { data, errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath,
|
||||
fileBuffer: Buffer.from(MALICIOUS_SVG, 'utf-8'),
|
||||
filename: 'malicious.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.uploadApplicationFile).toEqual({
|
||||
id: expect.any(String),
|
||||
path: `public-asset/${filePath}`,
|
||||
});
|
||||
|
||||
const [row] = await globalThis.testDataSource.query(
|
||||
`SELECT "mimeType" FROM core."file" WHERE id = $1`,
|
||||
[data.uploadApplicationFile.id],
|
||||
);
|
||||
|
||||
expect(row.mimeType).toBe('image/svg+xml');
|
||||
|
||||
const onDiskPath = join(
|
||||
STORAGE_LOCAL_PATH,
|
||||
TEST_WORKSPACE_ID,
|
||||
TEST_APP_ID,
|
||||
'public-asset',
|
||||
filePath,
|
||||
);
|
||||
|
||||
const storedContent = readFileSync(onDiskPath, 'utf-8');
|
||||
|
||||
expect(storedContent).toBe(EXPECTED_SANITIZED_MALICIOUS_SVG);
|
||||
}, 60000);
|
||||
|
||||
it('preserves a benign SVG with only DOMPurify structural normalization', async () => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const filePath = 'assets/benign.svg';
|
||||
|
||||
const { errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath,
|
||||
fileBuffer: Buffer.from(BENIGN_SVG, 'utf-8'),
|
||||
filename: 'benign.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
|
||||
const onDiskPath = join(
|
||||
STORAGE_LOCAL_PATH,
|
||||
TEST_WORKSPACE_ID,
|
||||
TEST_APP_ID,
|
||||
'public-asset',
|
||||
filePath,
|
||||
);
|
||||
|
||||
const storedContent = readFileSync(onDiskPath, 'utf-8');
|
||||
|
||||
expect(storedContent).toBe(EXPECTED_SANITIZED_BENIGN_SVG);
|
||||
}, 60000);
|
||||
});
|
||||
+91
-45
@@ -10,80 +10,119 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
|
||||
type TestContext = {
|
||||
type UploadInput = {
|
||||
fileFolder: string;
|
||||
fileFolderValue: FileFolder;
|
||||
filePath: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
fileContent: string;
|
||||
};
|
||||
|
||||
type UploadExpected = {
|
||||
fileFolderValue: FileFolder;
|
||||
persistedMimeType: string;
|
||||
};
|
||||
|
||||
type TestContext = {
|
||||
input: UploadInput;
|
||||
expected: UploadExpected;
|
||||
};
|
||||
|
||||
const SUCCESSFUL_TEST_CASES: EachTestingContext<TestContext>[] = [
|
||||
{
|
||||
title: 'when uploading a built front component',
|
||||
context: {
|
||||
fileFolder: 'BuiltFrontComponent',
|
||||
fileFolderValue: FileFolder.BuiltFrontComponent,
|
||||
filePath: 'src/components/my-component.mjs',
|
||||
filename: 'my-component.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default function MyComponent() {}',
|
||||
input: {
|
||||
fileFolder: 'BuiltFrontComponent',
|
||||
filePath: 'src/components/my-component.mjs',
|
||||
filename: 'my-component.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default function MyComponent() {}',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.BuiltFrontComponent,
|
||||
persistedMimeType: 'text/javascript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a built logic function',
|
||||
context: {
|
||||
fileFolder: 'BuiltLogicFunction',
|
||||
fileFolderValue: FileFolder.BuiltLogicFunction,
|
||||
filePath: 'src/handlers/my-handler.mjs',
|
||||
filename: 'my-handler.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default async function handler() {}',
|
||||
input: {
|
||||
fileFolder: 'BuiltLogicFunction',
|
||||
filePath: 'src/handlers/my-handler.mjs',
|
||||
filename: 'my-handler.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default async function handler() {}',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.BuiltLogicFunction,
|
||||
persistedMimeType: 'text/javascript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a source file',
|
||||
context: {
|
||||
fileFolder: 'Source',
|
||||
fileFolderValue: FileFolder.Source,
|
||||
filePath: 'src/index.tsx',
|
||||
filename: 'index.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const App = () => <div>Hello</div>;',
|
||||
input: {
|
||||
fileFolder: 'Source',
|
||||
filePath: 'src/index.tsx',
|
||||
filename: 'index.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const App = () => <div>Hello</div>;',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.Source,
|
||||
persistedMimeType: 'application/typescript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a public asset',
|
||||
context: {
|
||||
fileFolder: 'PublicAsset',
|
||||
fileFolderValue: FileFolder.PublicAsset,
|
||||
filePath: 'assets/logo.svg',
|
||||
filename: 'logo.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
fileContent: '<svg></svg>',
|
||||
input: {
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'assets/logo.svg',
|
||||
filename: 'logo.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
fileContent: '<svg></svg>',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.PublicAsset,
|
||||
persistedMimeType: 'image/svg+xml',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a dependencies file',
|
||||
context: {
|
||||
fileFolder: 'Dependencies',
|
||||
fileFolderValue: FileFolder.Dependencies,
|
||||
filePath: 'yarn.lock',
|
||||
filename: 'yarn.lock',
|
||||
contentType: 'text/plain',
|
||||
fileContent: '# yarn lockfile v1',
|
||||
input: {
|
||||
fileFolder: 'Dependencies',
|
||||
filePath: 'yarn.lock',
|
||||
filename: 'yarn.lock',
|
||||
contentType: 'text/plain',
|
||||
fileContent: '# yarn lockfile v1',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.Dependencies,
|
||||
persistedMimeType: 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a file in a nested directory path',
|
||||
context: {
|
||||
fileFolder: 'Source',
|
||||
fileFolderValue: FileFolder.Source,
|
||||
filePath: 'src/modules/auth/login/login.component.tsx',
|
||||
filename: 'login.component.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const Login = () => null;',
|
||||
input: {
|
||||
fileFolder: 'Source',
|
||||
filePath: 'src/modules/auth/login/login.component.tsx',
|
||||
filename: 'login.component.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const Login = () => null;',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.Source,
|
||||
persistedMimeType: 'application/typescript',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -106,16 +145,16 @@ describe('Upload application file should succeed', () => {
|
||||
|
||||
it.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
|
||||
'$title',
|
||||
async ({ context }) => {
|
||||
async ({ context: { input, expected } }) => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const { data, errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: context.fileFolder,
|
||||
filePath: context.filePath,
|
||||
fileBuffer: Buffer.from(context.fileContent),
|
||||
filename: context.filename,
|
||||
contentType: context.contentType,
|
||||
fileFolder: input.fileFolder,
|
||||
filePath: input.filePath,
|
||||
fileBuffer: Buffer.from(input.fileContent),
|
||||
filename: input.filename,
|
||||
contentType: input.contentType,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
@@ -124,8 +163,15 @@ describe('Upload application file should succeed', () => {
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.uploadApplicationFile).toEqual({
|
||||
id: expect.any(String),
|
||||
path: `${context.fileFolderValue}/${context.filePath}`,
|
||||
path: `${expected.fileFolderValue}/${input.filePath}`,
|
||||
});
|
||||
|
||||
const [row] = await globalThis.testDataSource.query(
|
||||
`SELECT "mimeType" FROM core."file" WHERE id = $1`,
|
||||
[data.uploadApplicationFile.id],
|
||||
);
|
||||
|
||||
expect(row.mimeType).toBe(expected.persistedMimeType);
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,11 @@ export type CommandMenuItemManifest = SyncableEntityOptions & {
|
||||
conditionalAvailabilityExpression?: string;
|
||||
};
|
||||
|
||||
export type FrontComponentCommandManifest = Omit<
|
||||
CommandMenuItemManifest,
|
||||
'frontComponentUniversalIdentifier'
|
||||
>;
|
||||
|
||||
export type FrontComponentManifest = {
|
||||
universalIdentifier: string;
|
||||
name?: string;
|
||||
|
||||
@@ -34,6 +34,7 @@ export type {
|
||||
} from './fieldManifestType';
|
||||
export type {
|
||||
CommandMenuItemManifest,
|
||||
FrontComponentCommandManifest,
|
||||
FrontComponentManifest,
|
||||
} from './frontComponentManifestType';
|
||||
export type { IndexFieldManifest } from './indexFieldManifestType';
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["generate-release-manifest"]
|
||||
"dependsOn": ["generate-release-manifest", "^build"]
|
||||
},
|
||||
"test": {},
|
||||
"fmt": {
|
||||
|
||||
Reference in New Issue
Block a user