@directus/api 33.3.1 → 34.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai/chat/lib/create-ui-stream.js +2 -1
- package/dist/ai/chat/lib/transform-file-parts.d.ts +12 -0
- package/dist/ai/chat/lib/transform-file-parts.js +36 -0
- package/dist/ai/files/adapters/anthropic.d.ts +3 -0
- package/dist/ai/files/adapters/anthropic.js +25 -0
- package/dist/ai/files/adapters/google.d.ts +3 -0
- package/dist/ai/files/adapters/google.js +58 -0
- package/dist/ai/files/adapters/index.d.ts +3 -0
- package/dist/ai/files/adapters/index.js +3 -0
- package/dist/ai/files/adapters/openai.d.ts +3 -0
- package/dist/ai/files/adapters/openai.js +22 -0
- package/dist/ai/files/controllers/upload.d.ts +2 -0
- package/dist/ai/files/controllers/upload.js +101 -0
- package/dist/ai/files/lib/fetch-provider.d.ts +1 -0
- package/dist/ai/files/lib/fetch-provider.js +23 -0
- package/dist/ai/files/lib/upload-to-provider.d.ts +4 -0
- package/dist/ai/files/lib/upload-to-provider.js +26 -0
- package/dist/ai/files/router.d.ts +1 -0
- package/dist/ai/files/router.js +5 -0
- package/dist/ai/files/types.d.ts +5 -0
- package/dist/ai/files/types.js +1 -0
- package/dist/ai/providers/anthropic-file-support.d.ts +12 -0
- package/dist/ai/providers/anthropic-file-support.js +94 -0
- package/dist/ai/providers/registry.js +3 -6
- package/dist/ai/tools/flows/index.d.ts +16 -16
- package/dist/ai/tools/schema.d.ts +8 -8
- package/dist/ai/tools/schema.js +2 -2
- package/dist/app.js +10 -1
- package/dist/controllers/deployment-webhooks.d.ts +2 -0
- package/dist/controllers/deployment-webhooks.js +95 -0
- package/dist/controllers/deployment.js +61 -165
- package/dist/controllers/files.js +2 -1
- package/dist/database/get-ast-from-query/lib/parse-fields.js +52 -26
- package/dist/database/helpers/date/dialects/oracle.js +2 -0
- package/dist/database/helpers/date/dialects/sqlite.js +2 -0
- package/dist/database/helpers/date/types.d.ts +1 -1
- package/dist/database/helpers/date/types.js +3 -1
- package/dist/database/helpers/fn/dialects/mssql.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/mssql.js +21 -0
- package/dist/database/helpers/fn/dialects/mysql.d.ts +2 -0
- package/dist/database/helpers/fn/dialects/mysql.js +30 -0
- package/dist/database/helpers/fn/dialects/oracle.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/oracle.js +21 -0
- package/dist/database/helpers/fn/dialects/postgres.d.ts +14 -0
- package/dist/database/helpers/fn/dialects/postgres.js +40 -0
- package/dist/database/helpers/fn/dialects/sqlite.d.ts +1 -0
- package/dist/database/helpers/fn/dialects/sqlite.js +12 -0
- package/dist/database/helpers/fn/json/parse-function.d.ts +19 -0
- package/dist/database/helpers/fn/json/parse-function.js +66 -0
- package/dist/database/helpers/fn/types.d.ts +8 -0
- package/dist/database/helpers/fn/types.js +19 -0
- package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
- package/dist/database/helpers/schema/dialects/mysql.js +11 -0
- package/dist/database/helpers/schema/types.d.ts +1 -0
- package/dist/database/helpers/schema/types.js +3 -0
- package/dist/database/index.js +2 -1
- package/dist/database/migrations/20260211A-add-deployment-webhooks.d.ts +3 -0
- package/dist/database/migrations/20260211A-add-deployment-webhooks.js +37 -0
- package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
- package/dist/database/run-ast/lib/apply-query/filter/operator.js +17 -7
- package/dist/database/run-ast/lib/parse-current-level.js +8 -1
- package/dist/database/run-ast/run-ast.js +11 -1
- package/dist/database/run-ast/utils/apply-function-to-column-name.js +7 -1
- package/dist/database/run-ast/utils/get-column.js +13 -2
- package/dist/deployment/deployment.d.ts +25 -2
- package/dist/deployment/drivers/netlify.d.ts +6 -2
- package/dist/deployment/drivers/netlify.js +114 -12
- package/dist/deployment/drivers/vercel.d.ts +5 -2
- package/dist/deployment/drivers/vercel.js +84 -5
- package/dist/deployment.d.ts +5 -0
- package/dist/deployment.js +34 -0
- package/dist/permissions/utils/get-unaliased-field-key.js +9 -1
- package/dist/request/is-denied-ip.js +24 -23
- package/dist/services/authentication.js +27 -22
- package/dist/services/collections.js +1 -0
- package/dist/services/deployment-projects.d.ts +31 -2
- package/dist/services/deployment-projects.js +109 -5
- package/dist/services/deployment-runs.d.ts +19 -1
- package/dist/services/deployment-runs.js +86 -0
- package/dist/services/deployment.d.ts +44 -3
- package/dist/services/deployment.js +263 -15
- package/dist/services/files/utils/get-metadata.js +6 -6
- package/dist/services/files.d.ts +3 -1
- package/dist/services/files.js +26 -3
- package/dist/services/graphql/resolvers/query.js +23 -6
- package/dist/services/payload.d.ts +6 -0
- package/dist/services/payload.js +27 -2
- package/dist/services/server.js +1 -1
- package/dist/services/users.js +6 -1
- package/dist/utils/get-field-relational-depth.d.ts +13 -0
- package/dist/utils/get-field-relational-depth.js +22 -0
- package/dist/utils/parse-value.d.ts +4 -0
- package/dist/utils/parse-value.js +11 -0
- package/dist/utils/sanitize-query.js +3 -2
- package/dist/utils/split-fields.d.ts +4 -0
- package/dist/utils/split-fields.js +32 -0
- package/dist/utils/validate-query.js +2 -1
- package/package.json +29 -29
|
@@ -1,6 +1,21 @@
|
|
|
1
|
+
import { InvalidQueryError } from '@directus/errors';
|
|
1
2
|
import { getOutputTypeForFunction } from '@directus/utils';
|
|
2
3
|
import { getHelpers } from '../../../../helpers/index.js';
|
|
3
4
|
import { getColumn } from '../../../utils/get-column.js';
|
|
5
|
+
function castToNumber(value) {
|
|
6
|
+
if (Array.isArray(value)) {
|
|
7
|
+
return value.map((val) => {
|
|
8
|
+
const num = Number(val);
|
|
9
|
+
if (Number.isNaN(num))
|
|
10
|
+
throw new InvalidQueryError({ reason: `Invalid numeric value` });
|
|
11
|
+
return num;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
const num = Number(value);
|
|
15
|
+
if (Number.isNaN(num))
|
|
16
|
+
throw new InvalidQueryError({ reason: `Invalid numeric value` });
|
|
17
|
+
return num;
|
|
18
|
+
}
|
|
4
19
|
export function applyOperator(knex, dbQuery, schema, key, operator, compareValue, logical = 'and', originalCollectionName) {
|
|
5
20
|
const helpers = getHelpers(knex);
|
|
6
21
|
const [table, column] = key.split('.');
|
|
@@ -47,7 +62,7 @@ export function applyOperator(knex, dbQuery, schema, key, operator, compareValue
|
|
|
47
62
|
const functionName = column.split('(')[0];
|
|
48
63
|
const type = getOutputTypeForFunction(functionName);
|
|
49
64
|
if (['integer', 'float', 'decimal'].includes(type)) {
|
|
50
|
-
compareValue =
|
|
65
|
+
compareValue = castToNumber(compareValue);
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
// Cast filter value (compareValue) based on type of field being filtered against
|
|
@@ -64,12 +79,7 @@ export function applyOperator(knex, dbQuery, schema, key, operator, compareValue
|
|
|
64
79
|
}
|
|
65
80
|
}
|
|
66
81
|
if (['integer', 'float', 'decimal'].includes(type)) {
|
|
67
|
-
|
|
68
|
-
compareValue = compareValue.map((val) => Number(val));
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
compareValue = Number(compareValue);
|
|
72
|
-
}
|
|
82
|
+
compareValue = castToNumber(compareValue);
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
if (operator === '_eq') {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseFilterKey } from '../../../utils/parse-filter-key.js';
|
|
2
|
+
import { parseJsonFunction } from '../../helpers/fn/json/parse-function.js';
|
|
2
3
|
export async function parseCurrentLevel(schema, collection, children, query) {
|
|
3
4
|
const primaryKeyField = schema.collections[collection].primary;
|
|
4
5
|
const columnsInCollection = Object.keys(schema.collections[collection].fields);
|
|
@@ -6,7 +7,13 @@ export async function parseCurrentLevel(schema, collection, children, query) {
|
|
|
6
7
|
const nestedCollectionNodes = [];
|
|
7
8
|
for (const child of children) {
|
|
8
9
|
if (child.type === 'field' || child.type === 'functionField') {
|
|
9
|
-
|
|
10
|
+
let fieldName;
|
|
11
|
+
if (child.type == 'functionField' && child.name.startsWith('json')) {
|
|
12
|
+
fieldName = parseJsonFunction(child.name).field;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
fieldName = parseFilterKey(child.name).fieldName;
|
|
16
|
+
}
|
|
10
17
|
if (columnsInCollection.includes(fieldName)) {
|
|
11
18
|
columnsToSelectInternal.push(child.fieldKey);
|
|
12
19
|
}
|
|
@@ -6,6 +6,7 @@ import { PayloadService } from '../../services/payload.js';
|
|
|
6
6
|
import getDatabase from '../index.js';
|
|
7
7
|
import { getDBQuery } from './lib/get-db-query.js';
|
|
8
8
|
import { parseCurrentLevel } from './lib/parse-current-level.js';
|
|
9
|
+
import { applyFunctionToColumnName } from './utils/apply-function-to-column-name.js';
|
|
9
10
|
import { applyParentFilters } from './utils/apply-parent-filters.js';
|
|
10
11
|
import { mergeWithParentItems } from './utils/merge-with-parent-items.js';
|
|
11
12
|
import { removeTemporaryFields } from './utils/remove-temporary-fields.js';
|
|
@@ -49,7 +50,16 @@ export async function runAst(originalAST, schema, accountability, options) {
|
|
|
49
50
|
return null;
|
|
50
51
|
// Run the items through the special transforms
|
|
51
52
|
const payloadService = new PayloadService(collection, { accountability, knex, schema });
|
|
52
|
-
|
|
53
|
+
// Build alias map that includes auto-generated json function field mappings
|
|
54
|
+
// so processJsonFunctionResults can detect and parse stringified JSON values
|
|
55
|
+
const aliasMap = { ...query.alias };
|
|
56
|
+
for (const child of children) {
|
|
57
|
+
if (child.type === 'functionField' && child.name.startsWith('json(')) {
|
|
58
|
+
const alias = applyFunctionToColumnName(child.fieldKey);
|
|
59
|
+
aliasMap[alias] = child.name;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let items = await payloadService.processValues('read', rawItems, aliasMap, query.aggregate ?? {});
|
|
53
63
|
if (!items || (Array.isArray(items) && items.length === 0))
|
|
54
64
|
return items;
|
|
55
65
|
// Apply the `_in` filters to the nested collection batches
|
|
@@ -14,7 +14,13 @@ export function applyFunctionToColumnName(column) {
|
|
|
14
14
|
if (column.includes('(') && column.includes(')')) {
|
|
15
15
|
const functionName = column.split('(')[0];
|
|
16
16
|
const columnName = column.match(REGEX_BETWEEN_PARENS)[1];
|
|
17
|
-
|
|
17
|
+
if (functionName === 'json') {
|
|
18
|
+
const slug = columnName?.replace(/[.[\],\s]+/g, '_');
|
|
19
|
+
return `${slug}${slug?.endsWith('_') ? '' : '_'}${functionName}`;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
return `${columnName}_${functionName}`;
|
|
23
|
+
}
|
|
18
24
|
}
|
|
19
25
|
else {
|
|
20
26
|
return column;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { REGEX_BETWEEN_PARENS } from '@directus/constants';
|
|
2
2
|
import { InvalidQueryError } from '@directus/errors';
|
|
3
3
|
import { getFunctionsForType } from '@directus/utils';
|
|
4
|
+
import { parseJsonFunction } from '../../helpers/fn/json/parse-function.js';
|
|
4
5
|
import { getFunctions } from '../../helpers/index.js';
|
|
5
6
|
import { applyFunctionToColumnName } from './apply-function-to-column-name.js';
|
|
6
7
|
/**
|
|
@@ -22,12 +23,21 @@ export function getColumn(knex, table, column, alias = applyFunctionToColumnName
|
|
|
22
23
|
const columnName = column.match(REGEX_BETWEEN_PARENS)[1];
|
|
23
24
|
if (functionName in fn) {
|
|
24
25
|
const collectionName = options?.originalCollectionName || table;
|
|
25
|
-
|
|
26
|
+
let fieldName = columnName;
|
|
27
|
+
let jsonPath;
|
|
28
|
+
// For json function, extract the base field name from the arguments
|
|
29
|
+
// json(metadata, color) -> metadata
|
|
30
|
+
if (functionName === 'json') {
|
|
31
|
+
const result = parseJsonFunction(column);
|
|
32
|
+
fieldName = result.field;
|
|
33
|
+
jsonPath = result.path;
|
|
34
|
+
}
|
|
35
|
+
const type = schema?.collections[collectionName]?.fields?.[fieldName]?.type ?? 'unknown';
|
|
26
36
|
const allowedFunctions = getFunctionsForType(type);
|
|
27
37
|
if (allowedFunctions.includes(functionName) === false) {
|
|
28
38
|
throw new InvalidQueryError({ reason: `Invalid function specified "${functionName}"` });
|
|
29
39
|
}
|
|
30
|
-
const result = fn[functionName](table,
|
|
40
|
+
const result = fn[functionName](table, fieldName, {
|
|
31
41
|
type,
|
|
32
42
|
relationalCountOptions: isFunctionColumnOptions(options)
|
|
33
43
|
? {
|
|
@@ -37,6 +47,7 @@ export function getColumn(knex, table, column, alias = applyFunctionToColumnName
|
|
|
37
47
|
}
|
|
38
48
|
: undefined,
|
|
39
49
|
originalCollectionName: options?.originalCollectionName,
|
|
50
|
+
jsonPath,
|
|
40
51
|
});
|
|
41
52
|
if (alias) {
|
|
42
53
|
return knex.raw(result + ' AS ??', [alias]);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Credentials, Deployment, Details, Log, Options, Project, TriggerResult } from '@directus/types';
|
|
1
|
+
import type { Credentials, Deployment, DeploymentWebhookEvent, Details, Log, Options, Project, Status, TriggerResult, WebhookRegistrationResult } from '@directus/types';
|
|
2
2
|
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
3
3
|
export type DeploymentRequestOptions = Pick<AxiosRequestConfig<string>, 'method' | 'headers'> & {
|
|
4
4
|
body?: string | null;
|
|
@@ -77,7 +77,7 @@ export declare abstract class DeploymentDriver<TCredentials extends Credentials
|
|
|
77
77
|
* @throws {HitRateLimitError} When rate limit is exceeded
|
|
78
78
|
* @throws {ServiceUnavailableError} When provider API fails
|
|
79
79
|
*/
|
|
80
|
-
abstract cancelDeployment(deploymentId: string): Promise<
|
|
80
|
+
abstract cancelDeployment(deploymentId: string): Promise<Status>;
|
|
81
81
|
/**
|
|
82
82
|
* Get deployment build logs
|
|
83
83
|
*
|
|
@@ -91,4 +91,27 @@ export declare abstract class DeploymentDriver<TCredentials extends Credentials
|
|
|
91
91
|
abstract getDeploymentLogs(deploymentId: string, options?: {
|
|
92
92
|
since?: Date;
|
|
93
93
|
}): Promise<Log[]>;
|
|
94
|
+
/**
|
|
95
|
+
* Register a webhook with the provider
|
|
96
|
+
*
|
|
97
|
+
* @param webhookUrl The public URL that will receive webhook events
|
|
98
|
+
* @param projectIds External project IDs to scope the webhook
|
|
99
|
+
* @returns Webhook ID and secret for signature verification
|
|
100
|
+
*/
|
|
101
|
+
abstract registerWebhook(webhookUrl: string, projectIds: string[]): Promise<WebhookRegistrationResult>;
|
|
102
|
+
/**
|
|
103
|
+
* Unregister webhooks from the provider
|
|
104
|
+
*
|
|
105
|
+
* @param webhookIds The webhook IDs returned from registerWebhook
|
|
106
|
+
*/
|
|
107
|
+
abstract unregisterWebhook(webhookIds: string[]): Promise<void>;
|
|
108
|
+
/**
|
|
109
|
+
* Verify webhook signature and parse the event payload
|
|
110
|
+
*
|
|
111
|
+
* @param rawBody Raw request body buffer (before JSON parsing)
|
|
112
|
+
* @param headers Request headers
|
|
113
|
+
* @param webhookSecret Secret used for signature verification
|
|
114
|
+
* @returns Parsed event or null if signature is invalid
|
|
115
|
+
*/
|
|
116
|
+
abstract verifyAndParseWebhook(rawBody: Buffer, headers: Record<string, string | string[] | undefined>, webhookSecret: string): DeploymentWebhookEvent | null;
|
|
94
117
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Credentials, Deployment, Details, Log, Options, Project, TriggerResult } from '@directus/types';
|
|
1
|
+
import type { Credentials, Deployment, DeploymentWebhookEvent, Details, Log, Options, Project, Status, TriggerResult, WebhookRegistrationResult } from '@directus/types';
|
|
2
2
|
import { DeploymentDriver } from '../deployment.js';
|
|
3
3
|
export interface NetlifyCredentials extends Credentials {
|
|
4
4
|
access_token: string;
|
|
@@ -22,11 +22,15 @@ export declare class NetlifyDriver extends DeploymentDriver<NetlifyCredentials,
|
|
|
22
22
|
preview?: boolean;
|
|
23
23
|
clearCache?: boolean;
|
|
24
24
|
}): Promise<TriggerResult>;
|
|
25
|
-
cancelDeployment(deploymentId: string): Promise<
|
|
25
|
+
cancelDeployment(deploymentId: string): Promise<Status>;
|
|
26
26
|
private closeWsConnection;
|
|
27
27
|
private setupWsIdleTimeout;
|
|
28
28
|
private setupWsConnectionTimeout;
|
|
29
29
|
private getWsConnection;
|
|
30
|
+
registerWebhook(webhookUrl: string, projectIds: string[]): Promise<WebhookRegistrationResult>;
|
|
31
|
+
private cleanupStaleHooks;
|
|
32
|
+
unregisterWebhook(webhookIds: string[]): Promise<void>;
|
|
33
|
+
verifyAndParseWebhook(rawBody: Buffer, headers: Record<string, string | string[] | undefined>, webhookSecret: string): DeploymentWebhookEvent | null;
|
|
30
34
|
getDeploymentLogs(deploymentId: string, options?: {
|
|
31
35
|
since?: Date;
|
|
32
36
|
}): Promise<Log[]>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { InvalidCredentialsError, ServiceUnavailableError } from '@directus/errors';
|
|
2
3
|
import { NetlifyAPI } from '@netlify/api';
|
|
3
4
|
import { isNumber } from 'lodash-es';
|
|
5
|
+
import { useLogger } from '../../logger/index.js';
|
|
4
6
|
import { DeploymentDriver } from '../deployment.js';
|
|
5
7
|
const WS_CONNECTIONS = new Map();
|
|
6
8
|
const WS_IDLE_TIMEOUT = 60_000; // 60 seconds
|
|
@@ -8,6 +10,13 @@ const WS_CONNECTION_TIMEOUT = 10_000; // 10 seconds
|
|
|
8
10
|
// eslint-disable-next-line no-control-regex
|
|
9
11
|
const ANSI_REGEX = /[\x1b]\[[0-9;]*m/g;
|
|
10
12
|
const WS_URL = 'wss://socketeer.services.netlify.com/build/logs';
|
|
13
|
+
const NETLIFY_WEBHOOK_EVENTS = ['deploy_created', 'deploy_building', 'deploy_failed', 'deploy_succeeded'];
|
|
14
|
+
// Map Netlify deploy state to our normalized types
|
|
15
|
+
const STATE_TO_EVENT = {
|
|
16
|
+
building: { type: 'deployment.created', status: 'building' },
|
|
17
|
+
ready: { type: 'deployment.succeeded', status: 'ready' },
|
|
18
|
+
error: { type: 'deployment.error', status: 'error' },
|
|
19
|
+
};
|
|
11
20
|
export class NetlifyDriver extends DeploymentDriver {
|
|
12
21
|
api;
|
|
13
22
|
constructor(credentials, options = {}) {
|
|
@@ -63,16 +72,23 @@ export class NetlifyDriver extends DeploymentDriver {
|
|
|
63
72
|
return result;
|
|
64
73
|
}
|
|
65
74
|
async listProjects() {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
const allSites = [];
|
|
76
|
+
const perPage = 100;
|
|
77
|
+
let hasMore = true;
|
|
78
|
+
for (let page = 1; hasMore; page++) {
|
|
79
|
+
const params = { per_page: String(perPage), page: String(page) };
|
|
80
|
+
const response = await this.handleApiError((api) => {
|
|
81
|
+
return this.options.account_slug
|
|
82
|
+
? api.listSitesForAccount({
|
|
83
|
+
account_slug: this.options.account_slug,
|
|
84
|
+
...params,
|
|
85
|
+
})
|
|
86
|
+
: api.listSites(params);
|
|
87
|
+
});
|
|
88
|
+
allSites.push(...response);
|
|
89
|
+
hasMore = response.length >= perPage;
|
|
90
|
+
}
|
|
91
|
+
return allSites.map((site) => this.mapSiteBase(site));
|
|
76
92
|
}
|
|
77
93
|
async getProject(projectId) {
|
|
78
94
|
const site = await this.handleApiError((api) => api.getSite({ siteId: projectId }));
|
|
@@ -149,12 +165,27 @@ export class NetlifyDriver extends DeploymentDriver {
|
|
|
149
165
|
const triggerResult = {
|
|
150
166
|
deployment_id: buildResponse.deploy_id,
|
|
151
167
|
status: this.mapStatus(deployState.state),
|
|
168
|
+
created_at: new Date(deployState.created_at),
|
|
152
169
|
};
|
|
153
170
|
return triggerResult;
|
|
154
171
|
}
|
|
155
172
|
async cancelDeployment(deploymentId) {
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
try {
|
|
174
|
+
await this.handleApiError((api) => api.cancelSiteDeploy({ deployId: deploymentId }));
|
|
175
|
+
this.closeWsConnection(deploymentId);
|
|
176
|
+
return 'canceled';
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
const details = await this.getDeployment(deploymentId);
|
|
180
|
+
if (details.status !== 'building') {
|
|
181
|
+
this.closeWsConnection(deploymentId);
|
|
182
|
+
return details.status;
|
|
183
|
+
}
|
|
184
|
+
throw new ServiceUnavailableError({
|
|
185
|
+
service: 'netlify',
|
|
186
|
+
reason: `Could not cancel the deployment: ${deploymentId}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
158
189
|
}
|
|
159
190
|
closeWsConnection(deploymentId, remove = true) {
|
|
160
191
|
const connection = WS_CONNECTIONS.get(deploymentId);
|
|
@@ -245,6 +276,77 @@ export class NetlifyDriver extends DeploymentDriver {
|
|
|
245
276
|
});
|
|
246
277
|
});
|
|
247
278
|
}
|
|
279
|
+
async registerWebhook(webhookUrl, projectIds) {
|
|
280
|
+
const logger = useLogger();
|
|
281
|
+
const secret = randomBytes(32).toString('hex');
|
|
282
|
+
const hookIds = [];
|
|
283
|
+
// Netlify API doesn't support JWS signing for API-created hooks,
|
|
284
|
+
// so we inject a token for verification instead
|
|
285
|
+
const signedUrl = `${webhookUrl}?token=${secret}`;
|
|
286
|
+
for (const siteId of projectIds) {
|
|
287
|
+
await this.cleanupStaleHooks(siteId, webhookUrl);
|
|
288
|
+
}
|
|
289
|
+
for (const siteId of projectIds) {
|
|
290
|
+
for (const event of NETLIFY_WEBHOOK_EVENTS) {
|
|
291
|
+
const hook = await this.handleApiError((api) => api.createHookBySiteId({
|
|
292
|
+
site_id: siteId,
|
|
293
|
+
body: { type: 'url', event, data: { url: signedUrl } },
|
|
294
|
+
}));
|
|
295
|
+
logger.debug(`[webhook:netlify] Created hook ${hook.id} for event ${event}`);
|
|
296
|
+
hookIds.push(hook.id);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { webhook_ids: hookIds, webhook_secret: secret };
|
|
300
|
+
}
|
|
301
|
+
async cleanupStaleHooks(siteId, webhookUrl) {
|
|
302
|
+
const logger = useLogger();
|
|
303
|
+
const hooks = await this.handleApiError((api) => api.listHooksBySiteId({ site_id: siteId }));
|
|
304
|
+
const staleHooks = hooks.filter((h) => h.data?.url?.startsWith(webhookUrl));
|
|
305
|
+
if (staleHooks.length > 0) {
|
|
306
|
+
logger.debug(`[webhook:netlify] Cleaning up ${staleHooks.length} stale hook(s) for site ${siteId}`);
|
|
307
|
+
await Promise.allSettled(staleHooks.map((h) => this.api.deleteHook({ hook_id: h.id })));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async unregisterWebhook(webhookIds) {
|
|
311
|
+
await Promise.allSettled(webhookIds.map((id) => this.api.deleteHook({ hook_id: id })));
|
|
312
|
+
}
|
|
313
|
+
verifyAndParseWebhook(rawBody, headers, webhookSecret) {
|
|
314
|
+
const logger = useLogger();
|
|
315
|
+
// URL token verification — Netlify API doesn't support JWS signing for API-created hooks,
|
|
316
|
+
// so we embed a secret token in the webhook URL and verify it here.
|
|
317
|
+
// The token is passed via the 'x-webhook-token'
|
|
318
|
+
const token = headers['x-webhook-token'];
|
|
319
|
+
if (!token || typeof token !== 'string') {
|
|
320
|
+
logger.warn(`[webhook:netlify] Missing webhook token`);
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const tokenBuf = Buffer.from(token);
|
|
324
|
+
const secretBuf = Buffer.from(webhookSecret);
|
|
325
|
+
if (tokenBuf.length !== secretBuf.length || !timingSafeEqual(tokenBuf, secretBuf)) {
|
|
326
|
+
logger.warn(`[webhook:netlify] Token mismatch`);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
// Parse deploy object from body
|
|
330
|
+
const deploy = JSON.parse(rawBody.toString('utf-8'));
|
|
331
|
+
const state = this.mapStatus(deploy.state);
|
|
332
|
+
const mapping = STATE_TO_EVENT[state];
|
|
333
|
+
if (!mapping) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
const url = deploy.ssl_url || deploy.deploy_ssl_url || deploy.url;
|
|
337
|
+
const timestamp = deploy.published_at || deploy.updated_at || deploy.created_at;
|
|
338
|
+
return {
|
|
339
|
+
type: mapping.type,
|
|
340
|
+
provider: 'netlify',
|
|
341
|
+
project_external_id: deploy.site_id,
|
|
342
|
+
deployment_external_id: deploy.id,
|
|
343
|
+
status: mapping.status,
|
|
344
|
+
...(url ? { url } : {}),
|
|
345
|
+
...(deploy.context ? { target: deploy.context } : {}),
|
|
346
|
+
timestamp: new Date(timestamp),
|
|
347
|
+
raw: deploy,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
248
350
|
async getDeploymentLogs(deploymentId, options) {
|
|
249
351
|
const deploy = await this.handleApiError((api) => api.getDeploy({ deployId: deploymentId }));
|
|
250
352
|
const connection = await this.getWsConnection(deploymentId);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Credentials, Deployment, Details, Log, Options, Project, TriggerResult } from '@directus/types';
|
|
1
|
+
import type { Credentials, Deployment, DeploymentWebhookEvent, Details, Log, Options, Project, Status, TriggerResult, WebhookRegistrationResult } from '@directus/types';
|
|
2
2
|
import { DeploymentDriver } from '../deployment.js';
|
|
3
3
|
export interface VercelCredentials extends Credentials {
|
|
4
4
|
access_token: string;
|
|
@@ -25,8 +25,11 @@ export declare class VercelDriver extends DeploymentDriver<VercelCredentials, Ve
|
|
|
25
25
|
preview?: boolean;
|
|
26
26
|
clearCache?: boolean;
|
|
27
27
|
}): Promise<TriggerResult>;
|
|
28
|
-
cancelDeployment(deploymentId: string): Promise<
|
|
28
|
+
cancelDeployment(deploymentId: string): Promise<Status>;
|
|
29
29
|
getDeploymentLogs(deploymentId: string, options?: {
|
|
30
30
|
since?: Date;
|
|
31
31
|
}): Promise<Log[]>;
|
|
32
|
+
registerWebhook(webhookUrl: string, projectIds: string[]): Promise<WebhookRegistrationResult>;
|
|
33
|
+
unregisterWebhook(webhookIds: string[]): Promise<void>;
|
|
34
|
+
verifyAndParseWebhook(rawBody: Buffer, headers: Record<string, string | string[] | undefined>, webhookSecret: string): DeploymentWebhookEvent | null;
|
|
32
35
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { HitRateLimitError, InvalidCredentialsError, ServiceUnavailableError } from '@directus/errors';
|
|
2
3
|
import pLimit from 'p-limit';
|
|
3
4
|
import { DeploymentDriver } from '../deployment.js';
|
|
5
|
+
const VERCEL_WEBHOOK_EVENTS = [
|
|
6
|
+
'deployment.created',
|
|
7
|
+
'deployment.succeeded',
|
|
8
|
+
'deployment.error',
|
|
9
|
+
'deployment.canceled',
|
|
10
|
+
];
|
|
4
11
|
export class VercelDriver extends DeploymentDriver {
|
|
5
12
|
static API_URL = 'https://api.vercel.com';
|
|
6
13
|
requestLimit = pLimit(5);
|
|
@@ -83,8 +90,14 @@ export class VercelDriver extends DeploymentDriver {
|
|
|
83
90
|
return result;
|
|
84
91
|
}
|
|
85
92
|
async listProjects() {
|
|
86
|
-
const
|
|
87
|
-
|
|
93
|
+
const allProjects = [];
|
|
94
|
+
let until;
|
|
95
|
+
do {
|
|
96
|
+
const response = await this.request('/v9/projects', { params: { limit: '100', ...(until ? { until } : {}) } });
|
|
97
|
+
allProjects.push(...response.projects.map((project) => this.mapProjectBase(project)));
|
|
98
|
+
until = response.pagination?.next ? String(response.pagination.next) : undefined;
|
|
99
|
+
} while (until);
|
|
100
|
+
return allProjects;
|
|
88
101
|
}
|
|
89
102
|
async getProject(projectId) {
|
|
90
103
|
const project = await this.request(`/v9/projects/${projectId}`);
|
|
@@ -171,6 +184,7 @@ export class VercelDriver extends DeploymentDriver {
|
|
|
171
184
|
const triggerResult = {
|
|
172
185
|
deployment_id: response.id,
|
|
173
186
|
status: this.mapStatus(response.status),
|
|
187
|
+
created_at: new Date(response.createdAt),
|
|
174
188
|
};
|
|
175
189
|
if (response.url) {
|
|
176
190
|
triggerResult.url = `https://${response.url}`;
|
|
@@ -178,9 +192,22 @@ export class VercelDriver extends DeploymentDriver {
|
|
|
178
192
|
return triggerResult;
|
|
179
193
|
}
|
|
180
194
|
async cancelDeployment(deploymentId) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
try {
|
|
196
|
+
await this.request(`/v12/deployments/${encodeURIComponent(deploymentId)}/cancel`, {
|
|
197
|
+
method: 'PATCH',
|
|
198
|
+
});
|
|
199
|
+
return 'canceled';
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
const details = await this.getDeployment(deploymentId);
|
|
203
|
+
if (details.status !== 'building') {
|
|
204
|
+
return details.status;
|
|
205
|
+
}
|
|
206
|
+
throw new ServiceUnavailableError({
|
|
207
|
+
service: 'vercel',
|
|
208
|
+
reason: `Could not cancel the deployment: ${deploymentId}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
184
211
|
}
|
|
185
212
|
async getDeploymentLogs(deploymentId, options) {
|
|
186
213
|
let url = `/v3/deployments/${encodeURIComponent(deploymentId)}/events`;
|
|
@@ -205,4 +232,56 @@ export class VercelDriver extends DeploymentDriver {
|
|
|
205
232
|
message: event.text || event.payload?.text || '',
|
|
206
233
|
}));
|
|
207
234
|
}
|
|
235
|
+
async registerWebhook(webhookUrl, projectIds) {
|
|
236
|
+
const response = await this.request('/v1/webhooks', {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
url: webhookUrl,
|
|
240
|
+
events: VERCEL_WEBHOOK_EVENTS,
|
|
241
|
+
projectIds,
|
|
242
|
+
}),
|
|
243
|
+
});
|
|
244
|
+
return {
|
|
245
|
+
webhook_ids: [response.id],
|
|
246
|
+
webhook_secret: response.secret,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async unregisterWebhook(webhookIds) {
|
|
250
|
+
for (const id of webhookIds) {
|
|
251
|
+
await this.request(`/v1/webhooks/${encodeURIComponent(id)}`, {
|
|
252
|
+
method: 'DELETE',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
verifyAndParseWebhook(rawBody, headers, webhookSecret) {
|
|
257
|
+
const signature = headers['x-vercel-signature'];
|
|
258
|
+
if (!signature || typeof signature !== 'string') {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const expected = createHmac('sha1', webhookSecret).update(rawBody).digest('hex');
|
|
262
|
+
if (signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const body = JSON.parse(rawBody.toString('utf-8'));
|
|
266
|
+
if (!VERCEL_WEBHOOK_EVENTS.includes(body.type)) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const eventTypeToStatus = {
|
|
270
|
+
'deployment.created': 'building',
|
|
271
|
+
'deployment.succeeded': 'ready',
|
|
272
|
+
'deployment.error': 'error',
|
|
273
|
+
'deployment.canceled': 'canceled',
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
type: body.type,
|
|
277
|
+
provider: 'vercel',
|
|
278
|
+
project_external_id: body.payload.project.id,
|
|
279
|
+
deployment_external_id: body.payload.deployment.id,
|
|
280
|
+
status: eventTypeToStatus[body.type] ?? 'building',
|
|
281
|
+
...(body.payload.deployment.url ? { url: `https://${body.payload.deployment.url}` } : {}),
|
|
282
|
+
...(body.payload.target ? { target: body.payload.target } : {}),
|
|
283
|
+
timestamp: new Date(body.createdAt),
|
|
284
|
+
raw: body,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
208
287
|
}
|
package/dist/deployment.d.ts
CHANGED
|
@@ -22,3 +22,8 @@ export declare function isValidProviderType(provider: string): provider is Provi
|
|
|
22
22
|
* Get list of supported provider types
|
|
23
23
|
*/
|
|
24
24
|
export declare function getSupportedProviderTypes(): ProviderType[];
|
|
25
|
+
/**
|
|
26
|
+
* Sync webhooks for existing deployment configs that don't have one yet.
|
|
27
|
+
* Called at startup to handle configs created before webhook support was added.
|
|
28
|
+
*/
|
|
29
|
+
export declare function ensureDeploymentWebhooks(): Promise<void>;
|
package/dist/deployment.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import getDatabase from './database/index.js';
|
|
1
2
|
import { NetlifyDriver, VercelDriver } from './deployment/drivers/index.js';
|
|
3
|
+
import { useLogger } from './logger/index.js';
|
|
4
|
+
import { DeploymentService } from './services/deployment.js';
|
|
5
|
+
import { getSchema } from './utils/get-schema.js';
|
|
2
6
|
/**
|
|
3
7
|
* Registry of deployment driver constructors
|
|
4
8
|
*/
|
|
@@ -38,3 +42,33 @@ export function isValidProviderType(provider) {
|
|
|
38
42
|
export function getSupportedProviderTypes() {
|
|
39
43
|
return Array.from(drivers.keys());
|
|
40
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Sync webhooks for existing deployment configs that don't have one yet.
|
|
47
|
+
* Called at startup to handle configs created before webhook support was added.
|
|
48
|
+
*/
|
|
49
|
+
export async function ensureDeploymentWebhooks() {
|
|
50
|
+
const logger = useLogger();
|
|
51
|
+
const knex = getDatabase();
|
|
52
|
+
const schema = await getSchema();
|
|
53
|
+
const service = new DeploymentService({
|
|
54
|
+
knex,
|
|
55
|
+
schema,
|
|
56
|
+
accountability: null,
|
|
57
|
+
});
|
|
58
|
+
const configs = await service.readByQuery({
|
|
59
|
+
limit: -1,
|
|
60
|
+
});
|
|
61
|
+
if (!configs || configs.length === 0) {
|
|
62
|
+
logger.debug('[webhook] No deployment configs found');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
logger.debug(`[webhook] Syncing webhooks for ${configs.length} config(s)...`);
|
|
66
|
+
for (const config of configs) {
|
|
67
|
+
try {
|
|
68
|
+
await service.syncWebhook(config.provider);
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
logger.error(`[webhook] Failed to sync webhook for ${config.provider}: ${err}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseJsonFunction } from '../../database/helpers/fn/json/parse-function.js';
|
|
1
2
|
import { parseFilterKey } from '../../utils/parse-filter-key.js';
|
|
2
3
|
/**
|
|
3
4
|
* Derive the unaliased field key from the given AST node.
|
|
@@ -10,8 +11,15 @@ export function getUnaliasedFieldKey(node) {
|
|
|
10
11
|
case 'm2o':
|
|
11
12
|
return node.relation.field;
|
|
12
13
|
case 'field':
|
|
13
|
-
case 'functionField':
|
|
14
14
|
// The field name might still include a function, so process that here as well
|
|
15
15
|
return parseFilterKey(node.name).fieldName;
|
|
16
|
+
case 'functionField':
|
|
17
|
+
if (node.name.startsWith('json')) {
|
|
18
|
+
return parseJsonFunction(node.name).field;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
// The field name might still include a function, so process that here as well
|
|
22
|
+
return parseFilterKey(node.name).fieldName;
|
|
23
|
+
}
|
|
16
24
|
}
|
|
17
25
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import os from 'node:os';
|
|
2
1
|
import { useEnv } from '@directus/env';
|
|
3
|
-
import {
|
|
4
|
-
import { matches } from 'ip-matching';
|
|
2
|
+
import { IpBlocklist } from '@directus/utils/node';
|
|
5
3
|
import { useLogger } from '../logger/index.js';
|
|
6
4
|
export function isDeniedIp(ip) {
|
|
7
5
|
const env = useEnv();
|
|
@@ -9,31 +7,34 @@ export function isDeniedIp(ip) {
|
|
|
9
7
|
const ipDenyList = env['IMPORT_IP_DENY_LIST'];
|
|
10
8
|
if (ipDenyList.length === 0)
|
|
11
9
|
return false;
|
|
10
|
+
const blockList = new IpBlocklist();
|
|
11
|
+
let blockNetworkInterfaces = false;
|
|
12
12
|
try {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
for (const blockNetworkRaw of ipDenyList) {
|
|
14
|
+
const blockNetwork = blockNetworkRaw.trim();
|
|
15
|
+
if (blockNetwork === '0.0.0.0') {
|
|
16
|
+
blockNetworkInterfaces = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (blockNetwork.includes('-')) {
|
|
20
|
+
blockList.parseRange(blockNetwork);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (blockNetwork.includes('/')) {
|
|
24
|
+
blockList.parseSubnet(blockNetwork);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
blockList.parseAddress(blockNetwork);
|
|
28
|
+
}
|
|
29
|
+
if (blockNetworkInterfaces) {
|
|
30
|
+
blockList.addLocalNetworkInterfaces();
|
|
31
|
+
}
|
|
16
32
|
}
|
|
17
33
|
catch (error) {
|
|
34
|
+
// error adding blocked ranges to the blocklist
|
|
18
35
|
logger.warn(`Cannot verify IP address due to invalid "IMPORT_IP_DENY_LIST" config`);
|
|
19
36
|
logger.warn(error);
|
|
20
37
|
return true;
|
|
21
38
|
}
|
|
22
|
-
|
|
23
|
-
const networkInterfaces = os.networkInterfaces();
|
|
24
|
-
for (const networkInfo of Object.values(networkInterfaces)) {
|
|
25
|
-
if (!networkInfo)
|
|
26
|
-
continue;
|
|
27
|
-
for (const info of networkInfo) {
|
|
28
|
-
if (info.internal && info.cidr) {
|
|
29
|
-
if (matches(ip, info.cidr))
|
|
30
|
-
return true;
|
|
31
|
-
}
|
|
32
|
-
else if (info.address === ip) {
|
|
33
|
-
return true;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
return false;
|
|
39
|
+
return blockList.checkAddress(ip);
|
|
39
40
|
}
|