@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.
Files changed (118) hide show
  1. package/dist/ai/chat/lib/create-ui-stream.js +2 -1
  2. package/dist/ai/chat/lib/transform-file-parts.d.ts +12 -0
  3. package/dist/ai/chat/lib/transform-file-parts.js +36 -0
  4. package/dist/ai/files/adapters/anthropic.d.ts +3 -0
  5. package/dist/ai/files/adapters/anthropic.js +25 -0
  6. package/dist/ai/files/adapters/google.d.ts +3 -0
  7. package/dist/ai/files/adapters/google.js +58 -0
  8. package/dist/ai/files/adapters/index.d.ts +3 -0
  9. package/dist/ai/files/adapters/index.js +3 -0
  10. package/dist/ai/files/adapters/openai.d.ts +3 -0
  11. package/dist/ai/files/adapters/openai.js +22 -0
  12. package/dist/ai/files/controllers/upload.d.ts +2 -0
  13. package/dist/ai/files/controllers/upload.js +101 -0
  14. package/dist/ai/files/lib/fetch-provider.d.ts +1 -0
  15. package/dist/ai/files/lib/fetch-provider.js +23 -0
  16. package/dist/ai/files/lib/upload-to-provider.d.ts +4 -0
  17. package/dist/ai/files/lib/upload-to-provider.js +26 -0
  18. package/dist/ai/files/router.d.ts +1 -0
  19. package/dist/ai/files/router.js +5 -0
  20. package/dist/ai/files/types.d.ts +5 -0
  21. package/dist/ai/files/types.js +1 -0
  22. package/dist/ai/providers/anthropic-file-support.d.ts +12 -0
  23. package/dist/ai/providers/anthropic-file-support.js +94 -0
  24. package/dist/ai/providers/registry.js +3 -6
  25. package/dist/ai/tools/fields/index.d.ts +3 -3
  26. package/dist/ai/tools/fields/index.js +9 -3
  27. package/dist/ai/tools/flows/index.d.ts +16 -16
  28. package/dist/ai/tools/schema.d.ts +8 -8
  29. package/dist/ai/tools/schema.js +2 -2
  30. package/dist/app.js +10 -1
  31. package/dist/auth/drivers/oauth2.js +10 -4
  32. package/dist/auth/drivers/openid.js +10 -4
  33. package/dist/auth/drivers/saml.js +20 -10
  34. package/dist/auth/utils/resolve-login-redirect.d.ts +11 -0
  35. package/dist/auth/utils/resolve-login-redirect.js +62 -0
  36. package/dist/controllers/deployment-webhooks.d.ts +2 -0
  37. package/dist/controllers/deployment-webhooks.js +95 -0
  38. package/dist/controllers/deployment.js +61 -165
  39. package/dist/controllers/files.js +2 -1
  40. package/dist/controllers/server.js +32 -26
  41. package/dist/controllers/tus.js +33 -2
  42. package/dist/controllers/utils.js +18 -0
  43. package/dist/database/get-ast-from-query/lib/parse-fields.js +52 -26
  44. package/dist/database/helpers/date/dialects/oracle.js +2 -0
  45. package/dist/database/helpers/date/dialects/sqlite.js +2 -0
  46. package/dist/database/helpers/date/types.d.ts +1 -1
  47. package/dist/database/helpers/date/types.js +3 -1
  48. package/dist/database/helpers/fn/dialects/mssql.d.ts +1 -0
  49. package/dist/database/helpers/fn/dialects/mssql.js +21 -0
  50. package/dist/database/helpers/fn/dialects/mysql.d.ts +2 -0
  51. package/dist/database/helpers/fn/dialects/mysql.js +30 -0
  52. package/dist/database/helpers/fn/dialects/oracle.d.ts +1 -0
  53. package/dist/database/helpers/fn/dialects/oracle.js +21 -0
  54. package/dist/database/helpers/fn/dialects/postgres.d.ts +14 -0
  55. package/dist/database/helpers/fn/dialects/postgres.js +40 -0
  56. package/dist/database/helpers/fn/dialects/sqlite.d.ts +1 -0
  57. package/dist/database/helpers/fn/dialects/sqlite.js +12 -0
  58. package/dist/database/helpers/fn/json/parse-function.d.ts +19 -0
  59. package/dist/database/helpers/fn/json/parse-function.js +66 -0
  60. package/dist/database/helpers/fn/types.d.ts +8 -0
  61. package/dist/database/helpers/fn/types.js +19 -0
  62. package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
  63. package/dist/database/helpers/schema/dialects/mysql.js +11 -0
  64. package/dist/database/helpers/schema/types.d.ts +1 -0
  65. package/dist/database/helpers/schema/types.js +3 -0
  66. package/dist/database/index.js +2 -1
  67. package/dist/database/migrations/20260211A-add-deployment-webhooks.d.ts +3 -0
  68. package/dist/database/migrations/20260211A-add-deployment-webhooks.js +37 -0
  69. package/dist/database/run-ast/lib/apply-query/aggregate.js +4 -4
  70. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  71. package/dist/database/run-ast/lib/apply-query/filter/operator.js +17 -7
  72. package/dist/database/run-ast/lib/parse-current-level.js +8 -1
  73. package/dist/database/run-ast/run-ast.js +11 -1
  74. package/dist/database/run-ast/utils/apply-function-to-column-name.js +7 -1
  75. package/dist/database/run-ast/utils/get-column.js +13 -2
  76. package/dist/deployment/deployment.d.ts +25 -2
  77. package/dist/deployment/drivers/netlify.d.ts +6 -2
  78. package/dist/deployment/drivers/netlify.js +114 -12
  79. package/dist/deployment/drivers/vercel.d.ts +5 -2
  80. package/dist/deployment/drivers/vercel.js +84 -5
  81. package/dist/deployment.d.ts +5 -0
  82. package/dist/deployment.js +34 -0
  83. package/dist/permissions/modules/validate-access/lib/validate-item-access.js +1 -1
  84. package/dist/permissions/utils/get-unaliased-field-key.js +9 -1
  85. package/dist/request/is-denied-ip.js +24 -23
  86. package/dist/services/authentication.js +27 -22
  87. package/dist/services/collections.js +1 -0
  88. package/dist/services/deployment-projects.d.ts +31 -2
  89. package/dist/services/deployment-projects.js +109 -5
  90. package/dist/services/deployment-runs.d.ts +19 -1
  91. package/dist/services/deployment-runs.js +86 -0
  92. package/dist/services/deployment.d.ts +44 -3
  93. package/dist/services/deployment.js +263 -15
  94. package/dist/services/files/utils/get-metadata.js +6 -6
  95. package/dist/services/files.d.ts +3 -1
  96. package/dist/services/files.js +26 -3
  97. package/dist/services/graphql/resolvers/query.js +23 -6
  98. package/dist/services/graphql/resolvers/system.js +35 -27
  99. package/dist/services/payload.d.ts +6 -0
  100. package/dist/services/payload.js +27 -2
  101. package/dist/services/server.js +1 -1
  102. package/dist/services/users.js +6 -1
  103. package/dist/test-utils/README.md +112 -0
  104. package/dist/test-utils/controllers.d.ts +65 -0
  105. package/dist/test-utils/controllers.js +100 -0
  106. package/dist/test-utils/database.d.ts +1 -1
  107. package/dist/test-utils/database.js +3 -1
  108. package/dist/utils/get-field-relational-depth.d.ts +13 -0
  109. package/dist/utils/get-field-relational-depth.js +22 -0
  110. package/dist/utils/parse-value.d.ts +4 -0
  111. package/dist/utils/parse-value.js +11 -0
  112. package/dist/utils/sanitize-query.js +3 -2
  113. package/dist/utils/split-fields.d.ts +4 -0
  114. package/dist/utils/split-fields.js +32 -0
  115. package/dist/utils/validate-query.js +2 -1
  116. package/package.json +36 -36
  117. package/dist/auth/utils/is-login-redirect-allowed.d.ts +0 -7
  118. package/dist/auth/utils/is-login-redirect-allowed.js +0 -39
@@ -1,8 +1,9 @@
1
- import { ErrorCode, ForbiddenError, InvalidPathParameterError, InvalidPayloadError, isDirectusError, } from '@directus/errors';
1
+ import { ErrorCode, InvalidPathParameterError, InvalidPayloadError, isDirectusError } from '@directus/errors';
2
2
  import { DEPLOYMENT_PROVIDER_TYPES } from '@directus/types';
3
3
  import express from 'express';
4
4
  import Joi from 'joi';
5
5
  import getDatabase from '../database/index.js';
6
+ import { useLogger } from '../logger/index.js';
6
7
  import { respond } from '../middleware/respond.js';
7
8
  import useCollection from '../middleware/use-collection.js';
8
9
  import { validateBatch } from '../middleware/validate-batch.js';
@@ -11,16 +12,14 @@ import { DeploymentRunsService } from '../services/deployment-runs.js';
11
12
  import { DeploymentService } from '../services/deployment.js';
12
13
  import { MetaService } from '../services/meta.js';
13
14
  import asyncHandler from '../utils/async-handler.js';
15
+ import { getMilliseconds } from '../utils/get-milliseconds.js';
14
16
  import { transaction } from '../utils/transaction.js';
15
17
  const router = express.Router();
18
+ function parseRange(range, defaultMs) {
19
+ const ms = getMilliseconds(range, defaultMs);
20
+ return new Date(Date.now() - ms);
21
+ }
16
22
  router.use(useCollection('directus_deployments'));
17
- // Require admin access for all deployment routes
18
- router.use((_req, _res, next) => {
19
- if (_req.accountability && _req.accountability.admin !== true) {
20
- throw new ForbiddenError();
21
- }
22
- return next();
23
- });
24
23
  // Validate provider parameter
25
24
  const validateProvider = (provider) => {
26
25
  return DEPLOYMENT_PROVIDER_TYPES.includes(provider);
@@ -101,40 +100,9 @@ router.get('/:provider/projects', asyncHandler(async (req, res, next) => {
101
100
  accountability: req.accountability,
102
101
  schema: req.schema,
103
102
  });
104
- // Get provider config to find deployment ID
105
103
  const deployment = await service.readByProvider(provider);
106
- // Get projects from provider (with cache)
107
104
  const { data: providerProjects, remainingTTL } = await service.listProviderProjects(provider);
108
- // Get selected projects from DB
109
- const selectedProjects = await projectsService.readByQuery({
110
- filter: { deployment: { _eq: deployment.id } },
111
- });
112
- // Map by external_id for quick lookup
113
- const selectedMap = new Map(selectedProjects.map((p) => [p.external_id, p]));
114
- // Sync names from provider
115
- const namesToUpdate = selectedProjects
116
- .map((dbProject) => {
117
- const providerProject = providerProjects.find((p) => p.id === dbProject.external_id);
118
- if (providerProject && providerProject.name !== dbProject.name) {
119
- return { id: dbProject.id, name: providerProject.name };
120
- }
121
- return null;
122
- })
123
- .filter((update) => update !== null);
124
- if (namesToUpdate.length > 0) {
125
- await projectsService.updateBatch(namesToUpdate);
126
- }
127
- // Merge with DB structure (id !== null means selected)
128
- const projects = providerProjects.map((project) => {
129
- return {
130
- id: selectedMap.get(project.id)?.id ?? null,
131
- external_id: project.id,
132
- name: project.name,
133
- deployable: project.deployable,
134
- framework: project.framework,
135
- };
136
- });
137
- // Pass remaining TTL for response headers
105
+ const projects = await projectsService.listWithSync(deployment.id, providerProjects);
138
106
  res.locals['cache'] = false;
139
107
  res.locals['cacheTTL'] = remainingTTL;
140
108
  res.locals['payload'] = { data: projects };
@@ -198,64 +166,42 @@ router.patch('/:provider/projects', asyncHandler(async (req, res, next) => {
198
166
  accountability: req.accountability,
199
167
  schema: req.schema,
200
168
  });
201
- // Get provider config
202
- const deployment = await service.readByProvider(provider);
203
169
  // Validate deployable projects before any mutation
204
170
  if (value.create.length > 0) {
205
- const driver = await service.getDriver(provider);
206
- const providerProjects = await driver.listProjects();
207
- const projectsMap = new Map(providerProjects.map((p) => [p.id, p]));
208
- const nonDeployable = value.create.filter((p) => !projectsMap.get(p.external_id)?.deployable);
209
- if (nonDeployable.length > 0) {
210
- const names = nonDeployable
211
- .map((p) => projectsMap.get(p.external_id)?.name || p.external_id)
212
- .join(', ');
213
- throw new InvalidPayloadError({
214
- reason: `Cannot add non-deployable projects: ${names}`,
215
- });
216
- }
171
+ await projectsService.validateDeployable(provider, value.create);
217
172
  }
218
- const updatedProjects = await projectsService.updateSelection(deployment.id, value.create, value.delete);
173
+ const updatedProjects = await projectsService.updateSelection(provider, value.create, value.delete);
174
+ // Sync webhook with updated project list
175
+ service.syncWebhook(provider).catch((err) => {
176
+ const logger = useLogger();
177
+ logger.error(`Failed to sync webhook for ${provider}: ${err}`);
178
+ });
219
179
  res.locals['payload'] = { data: updatedProjects };
220
180
  return next();
221
181
  }), respond);
182
+ const rangeQuerySchema = Joi.object({
183
+ range: Joi.string()
184
+ .pattern(/^\d+(ms|s|m|h|d|w|y)$/)
185
+ .optional(),
186
+ });
222
187
  // Dashboard - selected projects with stats
223
188
  router.get('/:provider/dashboard', asyncHandler(async (req, res, next) => {
224
189
  const provider = req.params['provider'];
225
190
  if (!validateProvider(provider)) {
226
191
  throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` });
227
192
  }
193
+ const { error, value } = rangeQuerySchema.validate(req.query);
194
+ if (error) {
195
+ throw new InvalidPayloadError({ reason: error.message });
196
+ }
197
+ const sinceDate = parseRange(value.range, 86_400_000);
228
198
  const service = new DeploymentService({
229
199
  accountability: req.accountability,
230
200
  schema: req.schema,
231
201
  });
232
- const projectsService = new DeploymentProjectsService({
233
- accountability: req.accountability,
234
- schema: req.schema,
235
- });
236
- // Get provider config
237
- const deployment = await service.readByProvider(provider);
238
- // Get selected projects from DB
239
- const selectedProjects = await projectsService.readByQuery({
240
- filter: { deployment: { _eq: deployment.id } },
241
- });
242
- if (selectedProjects.length === 0) {
243
- res.locals['payload'] = { data: { projects: [] } };
244
- return next();
245
- }
246
- // Fetch full details for each selected project (parallel)
247
- const driver = await service.getDriver(provider);
248
- const projectDetails = await Promise.all(selectedProjects.map(async (p) => {
249
- const details = await driver.getProject(p.external_id);
250
- return {
251
- ...details,
252
- id: p.id,
253
- external_id: p.external_id,
254
- };
255
- }));
256
- // Disable cache - dashboard needs fresh data from provider
202
+ const data = await service.getDashboard(provider, sinceDate);
257
203
  res.locals['cache'] = false;
258
- res.locals['payload'] = { data: { projects: projectDetails } };
204
+ res.locals['payload'] = { data };
259
205
  return next();
260
206
  }), respond);
261
207
  // Trigger deployment for a project
@@ -277,36 +223,11 @@ router.post('/:provider/projects/:id/deploy', asyncHandler(async (req, res, next
277
223
  accountability: req.accountability,
278
224
  schema: req.schema,
279
225
  });
280
- const projectsService = new DeploymentProjectsService({
281
- accountability: req.accountability,
282
- schema: req.schema,
283
- });
284
- const runsService = new DeploymentRunsService({
285
- accountability: req.accountability,
286
- schema: req.schema,
287
- });
288
- // Get project from DB
289
- const project = await projectsService.readOne(projectId);
290
- // Trigger deployment via driver
291
- const driver = await service.getDriver(provider);
292
- const result = await driver.triggerDeployment(project.external_id, {
226
+ const run = await service.triggerDeployment(provider, projectId, {
293
227
  preview: value.preview,
294
228
  clearCache: value.clear_cache,
295
229
  });
296
- // Store run in DB
297
- const runId = await runsService.createOne({
298
- project: projectId,
299
- external_id: result.deployment_id,
300
- target: value.preview ? 'preview' : 'production',
301
- });
302
- const run = await runsService.readOne(runId);
303
- res.locals['payload'] = {
304
- data: {
305
- ...run,
306
- status: result.status,
307
- url: result.url,
308
- },
309
- };
230
+ res.locals['payload'] = { data: run };
310
231
  return next();
311
232
  }), respond);
312
233
  // Update deployment config by provider
@@ -356,17 +277,11 @@ router.delete('/:provider', asyncHandler(async (req, _res, next) => {
356
277
  }), respond);
357
278
  // List runs for a project
358
279
  router.get('/:provider/projects/:id/runs', asyncHandler(async (req, res, next) => {
359
- // Disable cache - runs status needs to be fresh from provider
360
- res.locals['cache'] = false;
361
280
  const provider = req.params['provider'];
362
281
  const projectId = req.params['id'];
363
282
  if (!validateProvider(provider)) {
364
283
  throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` });
365
284
  }
366
- const service = new DeploymentService({
367
- accountability: req.accountability,
368
- schema: req.schema,
369
- });
370
285
  const projectsService = new DeploymentProjectsService({
371
286
  accountability: req.accountability,
372
287
  schema: req.schema,
@@ -377,7 +292,6 @@ router.get('/:provider/projects/:id/runs', asyncHandler(async (req, res, next) =
377
292
  });
378
293
  // Validate project exists
379
294
  await projectsService.readOne(projectId);
380
- // Get paginated runs from DB (default limit: 10)
381
295
  const query = {
382
296
  ...req.sanitizedQuery,
383
297
  filter: { project: { _eq: projectId } },
@@ -386,24 +300,39 @@ router.get('/:provider/projects/:id/runs', asyncHandler(async (req, res, next) =
386
300
  fields: ['*', 'user_created.first_name', 'user_created.last_name', 'user_created.email'],
387
301
  };
388
302
  const runs = await runsService.readByQuery(query);
389
- // Get pagination meta
390
303
  const metaService = new MetaService({
391
304
  accountability: req.accountability,
392
305
  schema: req.schema,
393
306
  });
394
307
  const meta = await metaService.getMetaForQuery('directus_deployment_runs', query);
395
- // Fetch status for each run from provider
396
- const driver = await service.getDriver(provider);
397
- const runsWithStatus = await Promise.all(runs.map(async (run) => {
398
- const details = await driver.getDeployment(run.external_id);
399
- return {
400
- ...run,
401
- ...details,
402
- id: run.id,
403
- external_id: run.external_id,
404
- };
405
- }));
406
- res.locals['payload'] = { data: runsWithStatus, meta };
308
+ res.locals['payload'] = { data: runs, meta };
309
+ return next();
310
+ }), respond);
311
+ // Project runs stats
312
+ router.get('/:provider/projects/:id/runs/stats', asyncHandler(async (req, res, next) => {
313
+ const provider = req.params['provider'];
314
+ const projectId = req.params['id'];
315
+ if (!validateProvider(provider)) {
316
+ throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` });
317
+ }
318
+ const { error, value } = rangeQuerySchema.validate(req.query);
319
+ if (error) {
320
+ throw new InvalidPayloadError({ reason: error.message });
321
+ }
322
+ const sinceDate = parseRange(value.range, 604_800_000).toISOString();
323
+ const projectsService = new DeploymentProjectsService({
324
+ accountability: req.accountability,
325
+ schema: req.schema,
326
+ });
327
+ const runsService = new DeploymentRunsService({
328
+ accountability: req.accountability,
329
+ schema: req.schema,
330
+ });
331
+ // Validate project exists and user has access
332
+ await projectsService.readOne(projectId);
333
+ const data = await runsService.getStats(projectId, sinceDate);
334
+ res.locals['cache'] = false;
335
+ res.locals['payload'] = { data };
407
336
  return next();
408
337
  }), respond);
409
338
  // Get single run details
@@ -421,31 +350,13 @@ router.get('/:provider/runs/:id', asyncHandler(async (req, res, next) => {
421
350
  if (error) {
422
351
  throw new InvalidPayloadError({ reason: error.message });
423
352
  }
424
- const sinceDate = value.since;
425
- const runsService = new DeploymentRunsService({
426
- accountability: req.accountability,
427
- schema: req.schema,
428
- });
429
353
  const service = new DeploymentService({
430
354
  accountability: req.accountability,
431
355
  schema: req.schema,
432
356
  });
433
- const run = await runsService.readOne(runId);
434
- const driver = await service.getDriver(provider);
435
- const [details, logs] = await Promise.all([
436
- driver.getDeployment(run.external_id),
437
- driver.getDeploymentLogs(run.external_id, sinceDate ? { since: sinceDate } : undefined),
438
- ]);
357
+ const data = await service.getRunWithLogs(provider, runId, value.since);
439
358
  res.locals['cache'] = false;
440
- res.locals['payload'] = {
441
- data: {
442
- ...run,
443
- ...details,
444
- id: run.id,
445
- external_id: run.external_id,
446
- logs,
447
- },
448
- };
359
+ res.locals['payload'] = { data };
449
360
  return next();
450
361
  }), respond);
451
362
  // Cancel a deployment
@@ -455,27 +366,12 @@ router.post('/:provider/runs/:id/cancel', asyncHandler(async (req, res, next) =>
455
366
  if (!validateProvider(provider)) {
456
367
  throw new InvalidPathParameterError({ reason: `${provider} is not a supported provider` });
457
368
  }
458
- const runsService = new DeploymentRunsService({
459
- accountability: req.accountability,
460
- schema: req.schema,
461
- });
462
369
  const service = new DeploymentService({
463
370
  accountability: req.accountability,
464
371
  schema: req.schema,
465
372
  });
466
- const run = await runsService.readOne(runId);
467
- const driver = await service.getDriver(provider);
468
- await driver.cancelDeployment(run.external_id);
469
- // Fetch updated status
470
- const details = await driver.getDeployment(run.external_id);
471
- res.locals['payload'] = {
472
- data: {
473
- ...run,
474
- ...details,
475
- id: run.id,
476
- external_id: run.external_id,
477
- },
478
- };
373
+ const data = await service.cancelDeployment(provider, runId);
374
+ res.locals['payload'] = { data };
479
375
  return next();
480
376
  }), respond);
481
377
  export default router;
@@ -150,6 +150,7 @@ router.post('/', asyncHandler(multipartHandler), asyncHandler(async (req, res, n
150
150
  const importSchema = Joi.object({
151
151
  url: Joi.string().required(),
152
152
  data: Joi.object(),
153
+ options: Joi.object({ filterMimeType: Joi.array().items(Joi.string()) }),
153
154
  });
154
155
  router.post('/import', asyncHandler(async (req, res, next) => {
155
156
  const { error } = importSchema.validate(req.body);
@@ -160,7 +161,7 @@ router.post('/import', asyncHandler(async (req, res, next) => {
160
161
  accountability: req.accountability,
161
162
  schema: req.schema,
162
163
  });
163
- const primaryKey = await service.importOne(req.body.url, req.body.data);
164
+ const primaryKey = await service.importOne(req.body.url, req.body.data, req.body.options);
164
165
  try {
165
166
  const record = await service.readOne(primaryKey, req.sanitizedQuery);
166
167
  res.locals['payload'] = { data: record || null };
@@ -1,3 +1,4 @@
1
+ import { useEnv } from '@directus/env';
1
2
  import { ErrorCode, ForbiddenError, isDirectusError, RouteNotFoundError } from '@directus/errors';
2
3
  import { format } from 'date-fns';
3
4
  import { Router } from 'express';
@@ -8,32 +9,37 @@ import { SpecificationService } from '../services/specifications.js';
8
9
  import asyncHandler from '../utils/async-handler.js';
9
10
  import { createAdmin } from '../utils/create-admin.js';
10
11
  const router = Router();
11
- router.get('/specs/oas', asyncHandler(async (req, res, next) => {
12
- const service = new SpecificationService({
13
- accountability: req.accountability,
14
- schema: req.schema,
15
- });
16
- res.locals['payload'] = await service.oas.generate(req.headers.host);
17
- return next();
18
- }), respond);
19
- router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
20
- const service = new SpecificationService({
21
- accountability: req.accountability,
22
- schema: req.schema,
23
- });
24
- const serverService = new ServerService({
25
- accountability: req.accountability,
26
- schema: req.schema,
27
- });
28
- const scope = req.params['scope'] || 'items';
29
- if (['items', 'system'].includes(scope) === false)
30
- throw new RouteNotFoundError({ path: req.path });
31
- const info = await serverService.serverInfo();
32
- const result = await service.graphql.generate(scope);
33
- const filename = info['project'].project_name + '_' + format(new Date(), 'yyyy-MM-dd') + '.graphql';
34
- res.attachment(filename);
35
- res.send(result);
36
- }));
12
+ const env = useEnv();
13
+ if (env['OPENAPI_ENABLED'] !== false) {
14
+ router.get('/specs/oas', asyncHandler(async (req, res, next) => {
15
+ const service = new SpecificationService({
16
+ accountability: req.accountability,
17
+ schema: req.schema,
18
+ });
19
+ res.locals['payload'] = await service.oas.generate(req.headers.host);
20
+ return next();
21
+ }), respond);
22
+ }
23
+ if (env['GRAPHQL_INTROSPECTION'] !== false) {
24
+ router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
25
+ const service = new SpecificationService({
26
+ accountability: req.accountability,
27
+ schema: req.schema,
28
+ });
29
+ const serverService = new ServerService({
30
+ accountability: req.accountability,
31
+ schema: req.schema,
32
+ });
33
+ const scope = req.params['scope'] || 'items';
34
+ if (['items', 'system'].includes(scope) === false)
35
+ throw new RouteNotFoundError({ path: req.path });
36
+ const info = await serverService.serverInfo();
37
+ const result = await service.graphql.generate(scope);
38
+ const filename = info['project'].project_name + '_' + format(new Date(), 'yyyy-MM-dd') + '.graphql';
39
+ res.attachment(filename);
40
+ res.send(result);
41
+ }));
42
+ }
37
43
  router.get('/info', asyncHandler(async (req, res, next) => {
38
44
  const service = new ServerService({
39
45
  accountability: req.accountability,
@@ -1,3 +1,5 @@
1
+ import { createError } from '@directus/errors';
2
+ import { ERRORS, Metadata } from '@tus/utils';
1
3
  import { Router } from 'express';
2
4
  import getDatabase from '../database/index.js';
3
5
  import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
@@ -18,11 +20,40 @@ const mapAction = (method) => {
18
20
  const checkFileAccess = asyncHandler(async (req, _res, next) => {
19
21
  if (req.accountability) {
20
22
  const action = mapAction(req.method);
21
- await validateAccess({
23
+ const validateAccessOptions = {
22
24
  action,
23
25
  collection: 'directus_files',
24
26
  accountability: req.accountability,
25
- }, {
27
+ };
28
+ if (req.method === 'POST' && req.header('upload-metadata')) {
29
+ let metadata;
30
+ try {
31
+ metadata = Metadata.parse(req.header('upload-metadata'));
32
+ }
33
+ catch {
34
+ throw new (createError('INVALID_METADATA', ERRORS.INVALID_METADATA.body, ERRORS.INVALID_METADATA.status_code))();
35
+ }
36
+ // On replacement ensure update for that record
37
+ if (metadata['id']) {
38
+ validateAccessOptions.action = 'update';
39
+ validateAccessOptions.primaryKeys = [metadata['id']];
40
+ }
41
+ // Validate permissions for any payload fields
42
+ const fields = [];
43
+ for (const field of Object.keys(req.schema.collections['directus_files'].fields)) {
44
+ // PK is not mutable, access to record is already checked via `primaryKeys` for updates
45
+ if (field === 'id') {
46
+ continue;
47
+ }
48
+ if (field in metadata) {
49
+ fields.push(field);
50
+ }
51
+ }
52
+ if (fields.length > 0) {
53
+ validateAccessOptions.fields = fields;
54
+ }
55
+ }
56
+ await validateAccess(validateAccessOptions, {
26
57
  schema: req.schema,
27
58
  knex: getDatabase(),
28
59
  });
@@ -3,6 +3,7 @@ import argon2 from 'argon2';
3
3
  import Busboy from 'busboy';
4
4
  import { Router } from 'express';
5
5
  import Joi from 'joi';
6
+ import { resolveLoginRedirect } from '../auth/utils/resolve-login-redirect.js';
6
7
  import collectionExists from '../middleware/collection-exists.js';
7
8
  import { respond } from '../middleware/respond.js';
8
9
  import { ExportService, ImportService } from '../services/import-export.js';
@@ -125,4 +126,21 @@ router.post('/cache/clear', asyncHandler(async (req, res) => {
125
126
  await service.clearCache({ system: clearSystemCache });
126
127
  res.status(200).end();
127
128
  }));
129
+ router.post('/resolve-redirect', asyncHandler(async (req, res) => {
130
+ if (!req.body?.redirect) {
131
+ throw new InvalidPayloadError({ reason: `"redirect" is required` });
132
+ }
133
+ if (req.body?.provider && typeof req.body.provider !== 'string') {
134
+ throw new InvalidPayloadError({ reason: `"provider" must be a string` });
135
+ }
136
+ try {
137
+ const resolved = resolveLoginRedirect(req.body.redirect, {
138
+ provider: req.body.provider,
139
+ });
140
+ return res.json({ data: resolved });
141
+ }
142
+ catch {
143
+ throw new InvalidPayloadError({ reason: `Invalid "redirect" provided` });
144
+ }
145
+ }));
128
146
  export default router;
@@ -1,5 +1,5 @@
1
1
  import { REGEX_BETWEEN_PARENS } from '@directus/constants';
2
- import { getRelation, getRelationType } from '@directus/utils';
2
+ import { getRelation, getRelationType, parseFilterFunctionPath } from '@directus/utils';
3
3
  import { isEmpty } from 'lodash-es';
4
4
  import { fetchPermissions } from '../../../permissions/lib/fetch-permissions.js';
5
5
  import { fetchPolicies } from '../../../permissions/lib/fetch-policies.js';
@@ -35,13 +35,58 @@ export async function parseFields(options, context) {
35
35
  name = options.query.alias[fieldKey];
36
36
  }
37
37
  }
38
- const isRelational = name.includes('.') ||
39
- // We'll always treat top level o2m fields as a related item. This is an alias field, otherwise it won't return
40
- // anything
41
- !!context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === name);
38
+ // Normalize function calls to move relational prefixes outside the function.
39
+ // e.g., json(m2m.data, color) m2m.json(data, color)
40
+ // This allows the standard relational handling below to process the traversal
41
+ // uniformly for all functions (json, count, year, etc.).
42
+ name = parseFilterFunctionPath(name);
43
+ const isFunctionCall = name.includes('(') && name.includes(')');
44
+ if (isFunctionCall) {
45
+ const functionName = name.split('(')[0];
46
+ const columnName = name.match(REGEX_BETWEEN_PARENS)[1];
47
+ const foundField = context.schema.collections[options.parentCollection].fields[columnName];
48
+ // Create a FunctionFieldNode for relational count functions (count(related_items))
49
+ if (functionName === 'count' && foundField && foundField.type === 'alias') {
50
+ const foundRelation = context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === columnName);
51
+ if (foundRelation) {
52
+ children.push({
53
+ type: 'functionField',
54
+ name,
55
+ fieldKey,
56
+ query: {},
57
+ relatedCollection: foundRelation.collection,
58
+ whenCase: [],
59
+ cases: [],
60
+ });
61
+ continue;
62
+ }
63
+ }
64
+ // Create a FunctionFieldNode for direct (non-relational) json function calls
65
+ if (functionName === 'json') {
66
+ children.push({
67
+ type: 'functionField',
68
+ name,
69
+ fieldKey,
70
+ query: {},
71
+ relatedCollection: options.parentCollection,
72
+ whenCase: [],
73
+ cases: [],
74
+ });
75
+ continue;
76
+ }
77
+ }
78
+ const isRelationalFunctionCall = isFunctionCall && name.includes('.') && name.indexOf('.') < name.indexOf('(');
79
+ const isRelational = (!isFunctionCall || isRelationalFunctionCall) &&
80
+ (name.includes('.') ||
81
+ // We'll always treat top level o2m fields as a related item. This is an alias field, otherwise it won't return
82
+ // anything
83
+ !!context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === name));
42
84
  if (isRelational) {
43
- // field is relational
44
- const parts = fieldKey.split('.');
85
+ // For normalized function calls, split on the resolved name since
86
+ // parseFilterFunctionPath may have moved relational segments outside
87
+ // the function args (e.g., json(m2m.data, color) → m2m.json(data, color)).
88
+ // For plain fields, split on fieldKey to preserve existing alias behavior.
89
+ const parts = (isFunctionCall ? name : fieldKey).split('.');
45
90
  let rootField = parts[0];
46
91
  let collectionScope = null;
47
92
  // a2o related collection scoped field selector `fields=sections.section_id:headings.title`
@@ -72,25 +117,6 @@ export async function parseFields(options, context) {
72
117
  }
73
118
  }
74
119
  else {
75
- if (name.includes('(') && name.includes(')')) {
76
- const columnName = name.match(REGEX_BETWEEN_PARENS)[1];
77
- const foundField = context.schema.collections[options.parentCollection].fields[columnName];
78
- if (foundField && foundField.type === 'alias') {
79
- const foundRelation = context.schema.relations.find((relation) => relation.related_collection === options.parentCollection && relation.meta?.one_field === columnName);
80
- if (foundRelation) {
81
- children.push({
82
- type: 'functionField',
83
- name,
84
- fieldKey,
85
- query: {},
86
- relatedCollection: foundRelation.collection,
87
- whenCase: [],
88
- cases: [],
89
- });
90
- continue;
91
- }
92
- }
93
- }
94
120
  if (name.includes(':')) {
95
121
  const [key, scope] = name.split(':');
96
122
  if (key in relationalStructure === false) {
@@ -16,6 +16,8 @@ export class DateHelperOracle extends DateHelper {
16
16
  }
17
17
  fieldFlagForField(fieldType) {
18
18
  switch (fieldType) {
19
+ case 'json':
20
+ return 'cast-json';
19
21
  case 'dateTime':
20
22
  return 'cast-datetime';
21
23
  default:
@@ -17,6 +17,8 @@ export class DateHelperSQLite extends DateHelper {
17
17
  }
18
18
  fieldFlagForField(fieldType) {
19
19
  switch (fieldType) {
20
+ case 'json':
21
+ return 'cast-json';
20
22
  case 'timestamp':
21
23
  return 'cast-timestamp';
22
24
  default:
@@ -3,5 +3,5 @@ export declare abstract class DateHelper extends DatabaseHelper {
3
3
  parse(date: string | Date): string;
4
4
  readTimestampString(date: string): string;
5
5
  writeTimestamp(date: string): Date;
6
- fieldFlagForField(_fieldType: string): string;
6
+ fieldFlagForField(fieldType: string): string;
7
7
  }
@@ -14,7 +14,9 @@ export class DateHelper extends DatabaseHelper {
14
14
  writeTimestamp(date) {
15
15
  return parseISO(date);
16
16
  }
17
- fieldFlagForField(_fieldType) {
17
+ fieldFlagForField(fieldType) {
18
+ if (fieldType === 'json')
19
+ return 'cast-json';
18
20
  return '';
19
21
  }
20
22
  }
@@ -11,4 +11,5 @@ export declare class FnHelperMSSQL extends FnHelper {
11
11
  minute(table: string, column: string, options: FnHelperOptions): Knex.Raw;
12
12
  second(table: string, column: string, options: FnHelperOptions): Knex.Raw;
13
13
  count(table: string, column: string, options?: FnHelperOptions): Knex.Raw<any>;
14
+ json(table: string, column: string, options?: FnHelperOptions): Knex.Raw;
14
15
  }