@directus/api 33.3.1 → 34.0.1
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/fields/index.d.ts +3 -3
- package/dist/ai/tools/fields/index.js +9 -3
- 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/auth/drivers/oauth2.js +10 -4
- package/dist/auth/drivers/openid.js +10 -4
- package/dist/auth/drivers/saml.js +20 -10
- package/dist/auth/utils/resolve-login-redirect.d.ts +11 -0
- package/dist/auth/utils/resolve-login-redirect.js +62 -0
- 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/controllers/server.js +32 -26
- package/dist/controllers/tus.js +33 -2
- package/dist/controllers/utils.js +18 -0
- 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/aggregate.js +4 -4
- 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/modules/validate-access/lib/validate-item-access.js +1 -1
- 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/graphql/resolvers/system.js +35 -27
- 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/test-utils/README.md +112 -0
- package/dist/test-utils/controllers.d.ts +65 -0
- package/dist/test-utils/controllers.js +100 -0
- package/dist/test-utils/database.d.ts +1 -1
- package/dist/test-utils/database.js +3 -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 +36 -36
- package/dist/auth/utils/is-login-redirect-allowed.d.ts +0 -7
- package/dist/auth/utils/is-login-redirect-allowed.js +0 -39
|
@@ -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
|
+
}
|
|
@@ -15,7 +15,7 @@ export async function validateItemAccess(options, context) {
|
|
|
15
15
|
const hasPrimaryKeys = options.primaryKeys && options.primaryKeys.length > 0;
|
|
16
16
|
// For non-singletons, we must have PKs to validate against
|
|
17
17
|
if (!isSingleton && !hasPrimaryKeys) {
|
|
18
|
-
|
|
18
|
+
return { accessAllowed: false };
|
|
19
19
|
}
|
|
20
20
|
// When we're looking up access to specific items, we have to read them from the database to
|
|
21
21
|
// make sure you are allowed to access them.
|
|
@@ -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
|
}
|
|
@@ -42,11 +42,25 @@ export class AuthenticationService {
|
|
|
42
42
|
const STALL_TIME = env['LOGIN_STALL_TIME'];
|
|
43
43
|
const timeStart = performance.now();
|
|
44
44
|
const provider = getAuthProvider(providerName);
|
|
45
|
+
const emitStatus = (status, loginPayload, loginUser, error) => {
|
|
46
|
+
emitter.emitAction('auth.login', {
|
|
47
|
+
payload: loginPayload,
|
|
48
|
+
status,
|
|
49
|
+
user: loginUser?.id,
|
|
50
|
+
provider: providerName,
|
|
51
|
+
error,
|
|
52
|
+
}, {
|
|
53
|
+
database: this.knex,
|
|
54
|
+
schema: this.schema,
|
|
55
|
+
accountability: this.accountability,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
45
58
|
let userId;
|
|
46
59
|
try {
|
|
47
60
|
userId = await provider.getUserID(cloneDeep(payload));
|
|
48
61
|
}
|
|
49
62
|
catch (err) {
|
|
63
|
+
emitStatus('fail', payload, undefined, err);
|
|
50
64
|
await stall(STALL_TIME, timeStart);
|
|
51
65
|
throw err;
|
|
52
66
|
}
|
|
@@ -64,22 +78,11 @@ export class AuthenticationService {
|
|
|
64
78
|
schema: this.schema,
|
|
65
79
|
accountability: this.accountability,
|
|
66
80
|
});
|
|
67
|
-
const emitStatus = (status) => {
|
|
68
|
-
emitter.emitAction('auth.login', {
|
|
69
|
-
payload: updatedPayload,
|
|
70
|
-
status,
|
|
71
|
-
user: user?.id,
|
|
72
|
-
provider: providerName,
|
|
73
|
-
}, {
|
|
74
|
-
database: this.knex,
|
|
75
|
-
schema: this.schema,
|
|
76
|
-
accountability: this.accountability,
|
|
77
|
-
});
|
|
78
|
-
};
|
|
79
81
|
if (user?.status !== 'active' || user?.provider !== providerName) {
|
|
80
|
-
|
|
82
|
+
const loginError = new InvalidCredentialsError();
|
|
83
|
+
emitStatus('fail', updatedPayload, user, loginError);
|
|
81
84
|
await stall(STALL_TIME, timeStart);
|
|
82
|
-
throw
|
|
85
|
+
throw loginError;
|
|
83
86
|
}
|
|
84
87
|
const settingsService = new SettingsService({
|
|
85
88
|
knex: this.knex,
|
|
@@ -130,23 +133,25 @@ export class AuthenticationService {
|
|
|
130
133
|
try {
|
|
131
134
|
await provider.login(clone(user), cloneDeep(updatedPayload));
|
|
132
135
|
}
|
|
133
|
-
catch (
|
|
134
|
-
emitStatus('fail');
|
|
136
|
+
catch (err) {
|
|
137
|
+
emitStatus('fail', updatedPayload, user, err);
|
|
135
138
|
await stall(STALL_TIME, timeStart);
|
|
136
|
-
throw
|
|
139
|
+
throw err;
|
|
137
140
|
}
|
|
138
141
|
if (user.tfa_secret && !options?.otp) {
|
|
139
|
-
|
|
142
|
+
const loginError = new InvalidOtpError();
|
|
143
|
+
emitStatus('fail', updatedPayload, user, loginError);
|
|
140
144
|
await stall(STALL_TIME, timeStart);
|
|
141
|
-
throw
|
|
145
|
+
throw loginError;
|
|
142
146
|
}
|
|
143
147
|
if (user.tfa_secret && options?.otp) {
|
|
144
148
|
const tfaService = new TFAService({ knex: this.knex, schema: this.schema });
|
|
145
149
|
const otpValid = await tfaService.verifyOTP(user.id, options?.otp);
|
|
146
150
|
if (otpValid === false) {
|
|
147
|
-
|
|
151
|
+
const loginError = new InvalidOtpError();
|
|
152
|
+
emitStatus('fail', updatedPayload, user, loginError);
|
|
148
153
|
await stall(STALL_TIME, timeStart);
|
|
149
|
-
throw
|
|
154
|
+
throw loginError;
|
|
150
155
|
}
|
|
151
156
|
}
|
|
152
157
|
const roles = await fetchRolesTree(user.role, { knex: this.knex });
|
|
@@ -214,7 +219,7 @@ export class AuthenticationService {
|
|
|
214
219
|
});
|
|
215
220
|
}
|
|
216
221
|
await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id });
|
|
217
|
-
emitStatus('success');
|
|
222
|
+
emitStatus('success', updatedPayload, user);
|
|
218
223
|
if (allowedAttempts !== null) {
|
|
219
224
|
await loginAttemptsLimiter.set(user.id, 0, 0);
|
|
220
225
|
}
|
|
@@ -52,6 +52,7 @@ export class CollectionsService {
|
|
|
52
52
|
if (payload.collection.startsWith('directus_')) {
|
|
53
53
|
throw new InvalidPayloadError({ reason: `Collections can't start with "directus_"` });
|
|
54
54
|
}
|
|
55
|
+
payload.collection = await this.helpers.schema.parseCollectionName(payload.collection);
|
|
55
56
|
const nestedActionEvents = [];
|
|
56
57
|
try {
|
|
57
58
|
const existingCollections = [
|