@directus/api 23.2.1 → 23.3.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.
@@ -141,7 +141,7 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
141
141
  const updatedUserPayload = await emitter.emitFilter(`auth.update`, emitPayload, {
142
142
  identifier,
143
143
  provider: this.config['provider'],
144
- providerPayload: { accessToken: tokenSet.access_token, userInfo },
144
+ providerPayload: { accessToken: tokenSet.access_token, idToken: tokenSet.id_token, userInfo },
145
145
  }, { database: getDatabase(), schema: this.schema, accountability: null });
146
146
  // Update user to update refresh_token and other properties that might have changed
147
147
  if (Object.values(updatedUserPayload).some((value) => value !== undefined)) {
@@ -159,7 +159,7 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
159
159
  const updatedUserPayload = await emitter.emitFilter(`auth.create`, userPayload, {
160
160
  identifier,
161
161
  provider: this.config['provider'],
162
- providerPayload: { accessToken: tokenSet.access_token, userInfo },
162
+ providerPayload: { accessToken: tokenSet.access_token, idToken: tokenSet.id_token, userInfo },
163
163
  }, { database: getDatabase(), schema: this.schema, accountability: null });
164
164
  try {
165
165
  await this.usersService.createOne(updatedUserPayload);
@@ -2,12 +2,14 @@ import { Router } from 'express';
2
2
  import type { Client } from 'openid-client';
3
3
  import { UsersService } from '../../services/users.js';
4
4
  import type { AuthDriverOptions, User } from '../../types/index.js';
5
+ import type { RoleMap } from '../../types/rolemap.js';
5
6
  import { LocalAuthDriver } from './local.js';
6
7
  export declare class OpenIDAuthDriver extends LocalAuthDriver {
7
8
  client: Promise<Client>;
8
9
  redirectUrl: string;
9
10
  usersService: UsersService;
10
11
  config: Record<string, any>;
12
+ roleMap: RoleMap;
11
13
  constructor(options: AuthDriverOptions, config: Record<string, any>);
12
14
  generateCodeVerifier(): string;
13
15
  generateAuthUrl(codeVerifier: string, prompt?: boolean): Promise<string>;
@@ -26,6 +26,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
26
26
  redirectUrl;
27
27
  usersService;
28
28
  config;
29
+ roleMap;
29
30
  constructor(options, config) {
30
31
  super(options, config);
31
32
  const env = useEnv();
@@ -40,6 +41,17 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
40
41
  this.redirectUrl = redirectUrl.toString();
41
42
  this.usersService = new UsersService({ knex: this.knex, schema: this.schema });
42
43
  this.config = additionalConfig;
44
+ this.roleMap = {};
45
+ const roleMapping = this.config['roleMapping'];
46
+ if (roleMapping) {
47
+ this.roleMap = roleMapping;
48
+ }
49
+ // role mapping will fail on login if AUTH_<provider>_ROLE_MAPPING is an array instead of an object.
50
+ // This happens if the 'json:' prefix is missing from the variable declaration. To save the user from exhaustive debugging, we'll try to fail early here.
51
+ if (roleMapping instanceof Array) {
52
+ logger.error("[OpenID] Expected a JSON-Object as role mapping, got an Array instead. Make sure you declare the variable with 'json:' prefix.");
53
+ throw new InvalidProviderError();
54
+ }
43
55
  this.client = new Promise((resolve, reject) => {
44
56
  Issuer.discover(issuerUrl)
45
57
  .then((issuer) => {
@@ -123,6 +135,21 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
123
135
  catch (e) {
124
136
  throw handleError(e);
125
137
  }
138
+ let role = this.config['defaultRoleId'];
139
+ const groupClaimName = this.config['groupClaimName'] ?? 'groups';
140
+ const groups = userInfo[groupClaimName];
141
+ if (Array.isArray(groups)) {
142
+ for (const key in this.roleMap) {
143
+ if (groups.includes(key)) {
144
+ // Overwrite default role if user is member of a group specified in roleMap
145
+ role = this.roleMap[key];
146
+ break;
147
+ }
148
+ }
149
+ }
150
+ else {
151
+ logger.debug(`[OpenID] Configured group claim with name "${groupClaimName}" does not exist or is empty.`);
152
+ }
126
153
  // Flatten response to support dot indexes
127
154
  userInfo = flatten(userInfo);
128
155
  const { provider, identifierKey, allowPublicRegistration, requireVerifiedEmail, syncUserInfo } = this.config;
@@ -139,7 +166,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
139
166
  last_name: userInfo['family_name'],
140
167
  email: email,
141
168
  external_identifier: identifier,
142
- role: this.config['defaultRoleId'],
169
+ role: role,
143
170
  auth_data: tokenSet.refresh_token && JSON.stringify({ refreshToken: tokenSet.refresh_token }),
144
171
  };
145
172
  const userId = await this.fetchUserId(identifier);
@@ -148,6 +175,8 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
148
175
  // user that is about to be updated
149
176
  let emitPayload = {
150
177
  auth_data: userPayload.auth_data,
178
+ // Make sure a user's role gets updated if his openid group or role mapping changes
179
+ role: role,
151
180
  };
152
181
  if (syncUserInfo) {
153
182
  emitPayload = {
@@ -160,7 +189,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
160
189
  const updatedUserPayload = await emitter.emitFilter(`auth.update`, emitPayload, {
161
190
  identifier,
162
191
  provider: this.config['provider'],
163
- providerPayload: { accessToken: tokenSet.access_token, userInfo },
192
+ providerPayload: { accessToken: tokenSet.access_token, idToken: tokenSet.id_token, userInfo },
164
193
  }, { database: getDatabase(), schema: this.schema, accountability: null });
165
194
  // Update user to update refresh_token and other properties that might have changed
166
195
  if (Object.values(updatedUserPayload).some((value) => value !== undefined)) {
@@ -179,7 +208,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
179
208
  const updatedUserPayload = await emitter.emitFilter(`auth.create`, userPayload, {
180
209
  identifier,
181
210
  provider: this.config['provider'],
182
- providerPayload: { accessToken: tokenSet.access_token, userInfo },
211
+ providerPayload: { accessToken: tokenSet.access_token, idToken: tokenSet.id_token, userInfo },
183
212
  }, { database: getDatabase(), schema: this.schema, accountability: null });
184
213
  try {
185
214
  await this.usersService.createOne(updatedUserPayload);
@@ -1,4 +1,5 @@
1
- export default function rolesCreate({ role: name, admin }: {
1
+ export default function rolesCreate({ role: name, admin, app, }: {
2
2
  role: string;
3
3
  admin: boolean;
4
+ app: boolean;
4
5
  }): Promise<void>;
@@ -1,8 +1,10 @@
1
1
  import { getSchema } from '../../../utils/get-schema.js';
2
2
  import { RolesService } from '../../../services/roles.js';
3
+ import { PoliciesService } from '../../../services/index.js';
4
+ import { AccessService } from '../../../services/index.js';
3
5
  import getDatabase from '../../../database/index.js';
4
6
  import { useLogger } from '../../../logger/index.js';
5
- export default async function rolesCreate({ role: name, admin }) {
7
+ export default async function rolesCreate({ role: name, admin, app, }) {
6
8
  const database = getDatabase();
7
9
  const logger = useLogger();
8
10
  if (!name) {
@@ -11,9 +13,21 @@ export default async function rolesCreate({ role: name, admin }) {
11
13
  }
12
14
  try {
13
15
  const schema = await getSchema();
14
- const service = new RolesService({ schema: schema, knex: database });
15
- const id = await service.createOne(admin ? { name, admin_access: admin } : { name });
16
- process.stdout.write(`${String(id)}\n`);
16
+ const rolesService = new RolesService({ schema: schema, knex: database });
17
+ const policiesService = new PoliciesService({ schema: schema, knex: database });
18
+ const accessService = new AccessService({ schema: schema, knex: database });
19
+ const adminPolicyId = await policiesService.createOne({
20
+ name: `Policy for ${name}`,
21
+ admin_access: admin,
22
+ app_access: app,
23
+ icon: 'supervised_user_circle',
24
+ });
25
+ const roleId = await rolesService.createOne({ name });
26
+ await accessService.createOne({
27
+ role: roleId,
28
+ policy: adminPolicyId,
29
+ });
30
+ process.stdout.write(`${String(roleId)}\n`);
17
31
  database.destroy();
18
32
  process.exit(0);
19
33
  }
package/dist/cli/index.js CHANGED
@@ -61,6 +61,7 @@ export async function createCli() {
61
61
  .description('Create a new role')
62
62
  .option('--role <value>', `name for the role`)
63
63
  .option('--admin', `whether or not the role has admin access`)
64
+ .option('--app', `whether or not the role has app access`)
64
65
  .action(rolesCreate);
65
66
  program.command('count <collection>').description('Count the amount of items in a given collection').action(count);
66
67
  program
@@ -1,4 +1,5 @@
1
1
  import { DateHelper } from '../types.js';
2
2
  export declare class DateHelperOracle extends DateHelper {
3
+ parse(date: string | Date): string;
3
4
  fieldFlagForField(fieldType: string): string;
4
5
  }
@@ -1,5 +1,19 @@
1
1
  import { DateHelper } from '../types.js';
2
2
  export class DateHelperOracle extends DateHelper {
3
+ // Required to handle timezoned offset
4
+ parse(date) {
5
+ if (!date) {
6
+ return date;
7
+ }
8
+ if (date instanceof Date) {
9
+ return String(date.toISOString());
10
+ }
11
+ // Return YY-MM-DD as is for date support
12
+ if (date.length <= 10 && date.includes('-')) {
13
+ return date;
14
+ }
15
+ return String(new Date(date).toISOString());
16
+ }
3
17
  fieldFlagForField(fieldType) {
4
18
  switch (fieldType) {
5
19
  case 'dateTime':
@@ -106,6 +106,16 @@ export function getDatabase() {
106
106
  callback(null, conn);
107
107
  };
108
108
  }
109
+ if (client === 'oracledb') {
110
+ poolConfig.afterCreate = async (conn, callback) => {
111
+ logger.trace('Setting OracleDB NLS_DATE_FORMAT and NLS_TIMESTAMP_FORMAT');
112
+ // enforce proper ISO standard 2024-12-10T10:54:00.123Z for datetime/timestamp
113
+ await conn.executeAsync('ALTER SESSION SET NLS_TIMESTAMP_FORMAT = \'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"\'');
114
+ // enforce 2024-12-10 date formet
115
+ await conn.executeAsync("ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD'");
116
+ callback(null, conn);
117
+ };
118
+ }
109
119
  if (client === 'mysql') {
110
120
  // Remove the conflicting `filename` option, defined by default in the Docker Image
111
121
  if (isObject(knexConfig.connection))
@@ -12,6 +12,8 @@ export async function up(knex) {
12
12
  }
13
13
  const rowsLimit = 50;
14
14
  let hasMore = true;
15
+ const existingUsers = new Set();
16
+ const missingUsers = new Set();
15
17
  while (hasMore) {
16
18
  const legacyComments = await knex
17
19
  .select('*')
@@ -28,12 +30,32 @@ export async function up(knex) {
28
30
  // Migrate legacy comment
29
31
  if (legacyComment['action'] === Action.COMMENT) {
30
32
  primaryKey = randomUUID();
33
+ let legacyCommentUserId = legacyComment.user;
34
+ if (legacyCommentUserId) {
35
+ if (missingUsers.has(legacyCommentUserId)) {
36
+ legacyCommentUserId = null;
37
+ }
38
+ else if (!existingUsers.has(legacyCommentUserId)) {
39
+ const userExists = await trx
40
+ .select('id')
41
+ .from('directus_users')
42
+ .where('id', '=', legacyCommentUserId)
43
+ .first();
44
+ if (userExists) {
45
+ existingUsers.add(legacyCommentUserId);
46
+ }
47
+ else {
48
+ missingUsers.add(legacyCommentUserId);
49
+ legacyCommentUserId = null;
50
+ }
51
+ }
52
+ }
31
53
  await trx('directus_comments').insert({
32
54
  id: primaryKey,
33
55
  collection: legacyComment.collection,
34
56
  item: legacyComment.item,
35
57
  comment: legacyComment.comment,
36
- user_created: legacyComment.user,
58
+ user_created: legacyCommentUserId,
37
59
  date_created: legacyComment.timestamp,
38
60
  });
39
61
  await trx('directus_activity')
@@ -33,10 +33,6 @@ export function applyParentFilters(schema, nestedCollectionNodes, parentItem) {
33
33
  const foreignField = nestedNode.relation.field;
34
34
  const foreignIds = uniq(parentItems.map((res) => res[nestedNode.parentKey])).filter((id) => !isNil(id));
35
35
  merge(nestedNode, { query: { filter: { [foreignField]: { _in: foreignIds } } } });
36
- if (nestedNode.relation.meta?.junction_field) {
37
- const junctionField = nestedNode.relation.meta.junction_field;
38
- merge(nestedNode, { query: { filter: { [junctionField]: { _nnull: true } } } });
39
- }
40
36
  }
41
37
  else if (nestedNode.type === 'a2o') {
42
38
  const keysPerCollection = {};
@@ -6,32 +6,59 @@ export function mergeWithParentItems(schema, nestedItem, parentItem, nestedNode,
6
6
  const nestedItems = toArray(nestedItem);
7
7
  const parentItems = clone(toArray(parentItem));
8
8
  if (nestedNode.type === 'm2o') {
9
- for (const parentItem of parentItems) {
10
- const itemChild = nestedItems.find((nestedItem) => {
11
- return (nestedItem[schema.collections[nestedNode.relation.related_collection].primary] ==
12
- parentItem[nestedNode.relation.field]);
13
- });
14
- parentItem[nestedNode.fieldKey] = itemChild || null;
9
+ const parentsByForeignKey = new Map();
10
+ parentItems.forEach((parentItem) => {
11
+ const relationKey = parentItem[nestedNode.relation.field];
12
+ if (!parentsByForeignKey.has(relationKey)) {
13
+ parentsByForeignKey.set(relationKey, []);
14
+ }
15
+ parentItem[nestedNode.fieldKey] = null;
16
+ parentsByForeignKey.get(relationKey).push(parentItem);
17
+ });
18
+ const nestPrimaryKeyField = schema.collections[nestedNode.relation.related_collection].primary;
19
+ for (const nestedItem of nestedItems) {
20
+ const nestedPK = nestedItem[nestPrimaryKeyField];
21
+ for (const parentItem of parentsByForeignKey.get(nestedPK)) {
22
+ parentItem[nestedNode.fieldKey] = nestedItem;
23
+ }
15
24
  }
16
25
  }
17
26
  else if (nestedNode.type === 'o2m') {
27
+ const parentCollectionName = nestedNode.relation.related_collection;
28
+ const parentPrimaryKeyField = schema.collections[parentCollectionName].primary;
29
+ const parentRelationField = nestedNode.fieldKey;
30
+ const nestedParentKeyField = nestedNode.relation.field;
31
+ const parentsByPrimaryKey = new Map();
32
+ parentItems.forEach((parentItem) => {
33
+ if (!parentItem[parentRelationField])
34
+ parentItem[parentRelationField] = [];
35
+ const parentPrimaryKey = parentItem[parentPrimaryKeyField];
36
+ if (parentsByPrimaryKey.has(parentPrimaryKey)) {
37
+ throw new Error(`Duplicate parent primary key '${parentPrimaryKey}' of '${parentCollectionName}' when merging o2m nested items`);
38
+ }
39
+ parentsByPrimaryKey.set(parentPrimaryKey, parentItem);
40
+ });
41
+ const toAddToAllParents = [];
42
+ nestedItems.forEach((nestedItem) => {
43
+ if (nestedItem === null)
44
+ return;
45
+ if (Array.isArray(nestedItem[nestedParentKeyField])) {
46
+ toAddToAllParents.push(nestedItem); // TODO explain this odd case
47
+ return; // Avoids adding the nestedItem twice
48
+ }
49
+ const parentPrimaryKey = nestedItem[nestedParentKeyField]?.[parentPrimaryKeyField] ?? nestedItem[nestedParentKeyField];
50
+ const parentItem = parentsByPrimaryKey.get(parentPrimaryKey);
51
+ if (!parentItem) {
52
+ throw new Error(`Missing parentItem '${nestedItem[nestedParentKeyField]}' of '${parentCollectionName}' when merging o2m nested items`);
53
+ }
54
+ parentItem[parentRelationField].push(nestedItem);
55
+ });
18
56
  for (const [index, parentItem] of parentItems.entries()) {
19
57
  if (fieldAllowed === false || (isArray(fieldAllowed) && !fieldAllowed[index])) {
20
58
  parentItem[nestedNode.fieldKey] = null;
21
59
  continue;
22
60
  }
23
- if (!parentItem[nestedNode.fieldKey])
24
- parentItem[nestedNode.fieldKey] = [];
25
- const itemChildren = nestedItems.filter((nestedItem) => {
26
- if (nestedItem === null)
27
- return false;
28
- if (Array.isArray(nestedItem[nestedNode.relation.field]))
29
- return true;
30
- return (nestedItem[nestedNode.relation.field] ==
31
- parentItem[schema.collections[nestedNode.relation.related_collection].primary] ||
32
- nestedItem[nestedNode.relation.field]?.[schema.collections[nestedNode.relation.related_collection].primary] == parentItem[schema.collections[nestedNode.relation.related_collection].primary]);
33
- });
34
- parentItem[nestedNode.fieldKey].push(...itemChildren);
61
+ parentItem[parentRelationField].push(...toAddToAllParents);
35
62
  const limit = nestedNode.query.limit ?? Number(env['QUERY_LIMIT_DEFAULT']);
36
63
  if (nestedNode.query.page && nestedNode.query.page > 1) {
37
64
  parentItem[nestedNode.fieldKey] = parentItem[nestedNode.fieldKey].slice(limit * (nestedNode.query.page - 1));
@@ -18,8 +18,8 @@ export async function fetchDynamicVariableContext(options, context) {
18
18
  }
19
19
  if (options.accountability.role && (permissionContext.$CURRENT_ROLE?.size ?? 0) > 0) {
20
20
  contextData['$CURRENT_ROLE'] = await fetchContextData('$CURRENT_ROLE', permissionContext, { role: options.accountability.role }, async (fields) => {
21
- const usersService = new RolesService(context);
22
- return await usersService.readOne(options.accountability.role, {
21
+ const rolesService = new RolesService(context);
22
+ return await rolesService.readOne(options.accountability.role, {
23
23
  fields,
24
24
  });
25
25
  });
@@ -1,6 +1,7 @@
1
1
  import { Action } from '@directus/constants';
2
2
  import { useEnv } from '@directus/env';
3
3
  import { toBoolean } from '@directus/utils';
4
+ import { getHelpers } from '../database/helpers/index.js';
4
5
  import getDatabase from '../database/index.js';
5
6
  import { useLock } from '../lock/index.js';
6
7
  import { useLogger } from '../logger/index.js';
@@ -31,6 +32,7 @@ export async function handleRetentionJob() {
31
32
  const batch = Number(env['RETENTION_BATCH']);
32
33
  const lockTime = await lock.get(retentionLockKey);
33
34
  const now = Date.now();
35
+ const helpers = getHelpers(database);
34
36
  if (lockTime && Number(lockTime) > now - retentionLockTimeout) {
35
37
  // ensure only one connected process
36
38
  return;
@@ -47,7 +49,7 @@ export async function handleRetentionJob() {
47
49
  .queryBuilder()
48
50
  .select(`${task.collection}.id`)
49
51
  .from(task.collection)
50
- .where('timestamp', '<', Date.now() - task.timeframe)
52
+ .where('timestamp', '<', helpers.date.parse(new Date(Date.now() - task.timeframe)))
51
53
  .limit(batch);
52
54
  if (task.where) {
53
55
  subquery.where(...task.where);
@@ -56,7 +58,19 @@ export async function handleRetentionJob() {
56
58
  subquery.join(...task.join);
57
59
  }
58
60
  try {
59
- count = await database(task.collection).where('id', 'in', subquery).delete();
61
+ let records = [];
62
+ const isMySQL = helpers.schema.isOneOfClients(['mysql']);
63
+ // mysql/maria does not allow limit within a subquery
64
+ // https://dev.mysql.com/doc/refman/8.4/en/subquery-restrictions.html
65
+ if (isMySQL) {
66
+ records = await subquery.then((r) => r.map((r) => r.id));
67
+ if (records.length === 0) {
68
+ break;
69
+ }
70
+ }
71
+ count = await database(task.collection)
72
+ .whereIn('id', isMySQL ? records : subquery)
73
+ .delete();
60
74
  }
61
75
  catch (error) {
62
76
  logger.error(error, `Retention failed for Collection ${task.collection}`);
@@ -132,12 +132,12 @@ export class CollectionsService {
132
132
  });
133
133
  }
134
134
  if (payload.meta) {
135
- const collectionItemsService = new ItemsService('directus_collections', {
135
+ const collectionsItemsService = new ItemsService('directus_collections', {
136
136
  knex: trx,
137
137
  accountability: this.accountability,
138
138
  schema: this.schema,
139
139
  });
140
- await collectionItemsService.createOne({
140
+ await collectionsItemsService.createOne({
141
141
  ...payload.meta,
142
142
  collection: payload.collection,
143
143
  }, {
@@ -210,13 +210,13 @@ export class CollectionsService {
210
210
  */
211
211
  async readByQuery() {
212
212
  const env = useEnv();
213
- const collectionItemsService = new ItemsService('directus_collections', {
213
+ const collectionsItemsService = new ItemsService('directus_collections', {
214
214
  knex: this.knex,
215
215
  schema: this.schema,
216
216
  accountability: this.accountability,
217
217
  });
218
218
  let tablesInDatabase = await this.schemaInspector.tableInfo();
219
- let meta = (await collectionItemsService.readByQuery({
219
+ let meta = (await collectionsItemsService.readByQuery({
220
220
  limit: -1,
221
221
  }));
222
222
  meta.push(...systemCollectionRows);
@@ -306,7 +306,7 @@ export class CollectionsService {
306
306
  }
307
307
  const nestedActionEvents = [];
308
308
  try {
309
- const collectionItemsService = new ItemsService('directus_collections', {
309
+ const collectionsItemsService = new ItemsService('directus_collections', {
310
310
  knex: this.knex,
311
311
  accountability: this.accountability,
312
312
  schema: this.schema,
@@ -321,13 +321,13 @@ export class CollectionsService {
321
321
  .where({ collection: collectionKey })
322
322
  .first());
323
323
  if (exists) {
324
- await collectionItemsService.updateOne(collectionKey, payload.meta, {
324
+ await collectionsItemsService.updateOne(collectionKey, payload.meta, {
325
325
  ...opts,
326
326
  bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
327
327
  });
328
328
  }
329
329
  else {
330
- await collectionItemsService.createOne({ ...payload.meta, collection: collectionKey }, {
330
+ await collectionsItemsService.createOne({ ...payload.meta, collection: collectionKey }, {
331
331
  ...opts,
332
332
  bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
333
333
  });
@@ -463,12 +463,12 @@ export class CollectionsService {
463
463
  // Make sure this collection isn't used as a group in any other collections
464
464
  await trx('directus_collections').update({ group: null }).where({ group: collectionKey });
465
465
  if (collectionToBeDeleted.meta) {
466
- const collectionItemsService = new ItemsService('directus_collections', {
466
+ const collectionsItemsService = new ItemsService('directus_collections', {
467
467
  knex: trx,
468
468
  accountability: this.accountability,
469
469
  schema: this.schema,
470
470
  });
471
- await collectionItemsService.deleteOne(collectionKey, {
471
+ await collectionsItemsService.deleteOne(collectionKey, {
472
472
  bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
473
473
  });
474
474
  }
@@ -133,11 +133,11 @@ export class FilesService extends ItemsService {
133
133
  payload.uploaded_on = new Date().toISOString();
134
134
  // We do this in a service without accountability. Even if you don't have update permissions to the file,
135
135
  // we still want to be able to set the extracted values from the file on create
136
- const sudoService = new ItemsService('directus_files', {
136
+ const sudoFilesItemsService = new ItemsService('directus_files', {
137
137
  knex: this.knex,
138
138
  schema: this.schema,
139
139
  });
140
- await sudoService.updateOne(primaryKey, { ...payload, ...metadata }, { emitEvents: false });
140
+ await sudoFilesItemsService.updateOne(primaryKey, { ...payload, ...metadata }, { emitEvents: false });
141
141
  if (opts?.emitEvents !== false) {
142
142
  emitter.emitAction('files.upload', {
143
143
  payload,
@@ -144,13 +144,13 @@ export class PayloadService {
144
144
  return fieldsInPayload.includes(name);
145
145
  });
146
146
  }
147
- await Promise.all(processedPayload.map(async (record) => {
148
- await Promise.all(specialFields.map(async ([name, field]) => {
147
+ for (const record of processedPayload) {
148
+ for (const [name, field] of specialFields) {
149
149
  const newValue = await this.processField(field, record, action, this.accountability);
150
150
  if (newValue !== undefined)
151
151
  record[name] = newValue;
152
- }));
153
- }));
152
+ }
153
+ }
154
154
  this.processGeometries(processedPayload, action);
155
155
  this.processDates(processedPayload, action);
156
156
  if (['create', 'update'].includes(action)) {
@@ -39,7 +39,7 @@ export class RolesService extends ItemsService {
39
39
  accountability: this.accountability,
40
40
  schema: this.schema,
41
41
  };
42
- const itemsService = new ItemsService('directus_roles', options);
42
+ const rolesItemsService = new ItemsService('directus_roles', options);
43
43
  const rolesService = new RolesService(options);
44
44
  const accessService = new AccessService(options);
45
45
  const presetsService = new PresetsService(options);
@@ -62,7 +62,7 @@ export class RolesService extends ItemsService {
62
62
  await rolesService.updateByQuery({
63
63
  filter: { parent: { _in: keys } },
64
64
  }, { parent: null });
65
- await itemsService.deleteMany(keys, opts);
65
+ await rolesItemsService.deleteMany(keys, opts);
66
66
  });
67
67
  // Since nested roles could be updated, clear caches
68
68
  await this.clearCaches();
@@ -29,7 +29,7 @@ export class TusDataStore extends DataStore {
29
29
  async create(upload) {
30
30
  const logger = useLogger();
31
31
  const knex = getDatabase();
32
- const itemsService = new ItemsService('directus_files', {
32
+ const filesItemsService = new ItemsService('directus_files', {
33
33
  accountability: this.accountability,
34
34
  schema: this.schema,
35
35
  knex,
@@ -78,7 +78,7 @@ export class TusDataStore extends DataStore {
78
78
  }
79
79
  }
80
80
  // If this is a new file upload, we need to generate a new primary key and DB record
81
- const primaryKey = await itemsService.createOne(fileData, { emitEvents: false });
81
+ const primaryKey = await filesItemsService.createOne(fileData, { emitEvents: false });
82
82
  // Set the file id, so it is available to be sent as a header on upload creation / resume
83
83
  if (!upload.metadata['id']) {
84
84
  upload.metadata['id'] = primaryKey;
@@ -92,17 +92,17 @@ export class TusDataStore extends DataStore {
92
92
  // If this is a replacement, we'll write the file to a temp location first to ensure we don't overwrite the existing file if something goes wrong
93
93
  upload = (await this.storageDriver.createChunkedUpload(fileData.filename_disk, upload));
94
94
  fileData.tus_data = upload;
95
- await itemsService.updateOne(primaryKey, fileData, { emitEvents: false });
95
+ await filesItemsService.updateOne(primaryKey, fileData, { emitEvents: false });
96
96
  return upload;
97
97
  }
98
98
  catch (err) {
99
99
  logger.warn(`Couldn't create chunked upload for ${fileData.filename_disk}`);
100
100
  logger.warn(err);
101
101
  if (isReplacement) {
102
- await itemsService.updateOne(primaryKey, { tus_id: null, tus_data: null }, { emitEvents: false });
102
+ await filesItemsService.updateOne(primaryKey, { tus_id: null, tus_data: null }, { emitEvents: false });
103
103
  }
104
104
  else {
105
- await itemsService.deleteOne(primaryKey, { emitEvents: false });
105
+ await filesItemsService.deleteOne(primaryKey, { emitEvents: false });
106
106
  }
107
107
  throw ERRORS.UNKNOWN_ERROR;
108
108
  }
@@ -111,12 +111,12 @@ export class TusDataStore extends DataStore {
111
111
  const logger = useLogger();
112
112
  const fileData = await this.getFileById(tus_id);
113
113
  const filePath = fileData.filename_disk;
114
- const sudoService = new ItemsService('directus_files', {
114
+ const sudoFilesItemsService = new ItemsService('directus_files', {
115
115
  schema: this.schema,
116
116
  });
117
117
  try {
118
118
  const newOffset = await this.storageDriver.writeChunk(filePath, readable, offset, fileData.tus_data);
119
- await sudoService.updateOne(fileData.id, {
119
+ await sudoFilesItemsService.updateOne(fileData.id, {
120
120
  tus_data: {
121
121
  ...fileData.tus_data,
122
122
  offset: newOffset,
@@ -134,7 +134,7 @@ export class TusDataStore extends DataStore {
134
134
  // If the file is a replacement, delete the old files, and upgrade the temp file
135
135
  if (isReplacement === true) {
136
136
  const replaceId = fileData.tus_data['metadata']['replace_id'];
137
- const replaceData = await sudoService.readOne(replaceId, { fields: ['filename_disk'] });
137
+ const replaceData = await sudoFilesItemsService.readOne(replaceId, { fields: ['filename_disk'] });
138
138
  // delete the previously saved file and thumbnails to ensure they're generated fresh
139
139
  for await (const partPath of this.storageDriver.list(replaceId)) {
140
140
  await this.storageDriver.delete(partPath);
@@ -154,20 +154,20 @@ export class TusDataStore extends DataStore {
154
154
  }
155
155
  }
156
156
  async remove(tus_id) {
157
- const sudoService = new ItemsService('directus_files', {
157
+ const sudoFilesItemsService = new ItemsService('directus_files', {
158
158
  schema: this.schema,
159
159
  });
160
160
  const fileData = await this.getFileById(tus_id);
161
161
  await this.storageDriver.deleteChunkedUpload(fileData.filename_disk, fileData.tus_data);
162
- await sudoService.deleteOne(fileData.id);
162
+ await sudoFilesItemsService.deleteOne(fileData.id);
163
163
  }
164
164
  async deleteExpired() {
165
- const sudoService = new ItemsService('directus_files', {
165
+ const sudoFilesItemsService = new ItemsService('directus_files', {
166
166
  schema: this.schema,
167
167
  });
168
168
  const now = new Date();
169
169
  const toDelete = [];
170
- const uploadFiles = await sudoService.readByQuery({
170
+ const uploadFiles = await sudoFilesItemsService.readByQuery({
171
171
  fields: ['modified_on', 'tus_id', 'tus_data'],
172
172
  filter: { tus_id: { _nnull: true } },
173
173
  });
@@ -197,10 +197,10 @@ export class TusDataStore extends DataStore {
197
197
  return new Upload(fileData.tus_data);
198
198
  }
199
199
  async getFileById(tus_id) {
200
- const itemsService = new ItemsService('directus_files', {
200
+ const sudoFilesItemsService = new ItemsService('directus_files', {
201
201
  schema: this.schema,
202
202
  });
203
- const results = await itemsService.readByQuery({
203
+ const results = await sudoFilesItemsService.readByQuery({
204
204
  filter: {
205
205
  tus_id: { _eq: tus_id },
206
206
  storage: { _eq: this.location },
@@ -0,0 +1,3 @@
1
+ export type RoleMap = {
2
+ [key: string]: string;
3
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,15 +1,14 @@
1
- import { getCache } from '../cache.js';
1
+ import { flushCaches } from '../cache.js';
2
2
  import getDatabase from '../database/index.js';
3
3
  import { applyDiff } from './apply-diff.js';
4
4
  import { getSchema } from './get-schema.js';
5
- import { getSnapshot } from './get-snapshot.js';
6
5
  import { getSnapshotDiff } from './get-snapshot-diff.js';
6
+ import { getSnapshot } from './get-snapshot.js';
7
7
  export async function applySnapshot(snapshot, options) {
8
8
  const database = options?.database ?? getDatabase();
9
9
  const schema = options?.schema ?? (await getSchema({ database, bypassCache: true }));
10
- const { systemCache } = getCache();
11
10
  const current = options?.current ?? (await getSnapshot({ database, schema }));
12
11
  const snapshotDiff = options?.diff ?? getSnapshotDiff(current, snapshot);
13
12
  await applyDiff(current, snapshotDiff, { database, schema });
14
- await systemCache?.clear();
13
+ await flushCaches();
15
14
  }
@@ -10,7 +10,7 @@ export function startWebSocketHandlers() {
10
10
  const restEnabled = toBoolean(env['WEBSOCKETS_REST_ENABLED']);
11
11
  const graphqlEnabled = toBoolean(env['WEBSOCKETS_GRAPHQL_ENABLED']);
12
12
  const logsEnabled = toBoolean(env['WEBSOCKETS_LOGS_ENABLED']);
13
- if (heartbeatEnabled) {
13
+ if (restEnabled && heartbeatEnabled) {
14
14
  new HeartbeatHandler();
15
15
  }
16
16
  if (restEnabled || graphqlEnabled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/api",
3
- "version": "23.2.1",
3
+ "version": "23.3.0",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "keywords": [
6
6
  "directus",
@@ -149,29 +149,29 @@
149
149
  "ws": "8.18.0",
150
150
  "zod": "3.23.8",
151
151
  "zod-validation-error": "3.4.0",
152
- "@directus/app": "13.3.5",
152
+ "@directus/app": "13.3.7",
153
153
  "@directus/constants": "12.0.1",
154
- "@directus/errors": "1.0.1",
155
154
  "@directus/env": "4.1.0",
156
- "@directus/extensions": "2.0.6",
157
- "@directus/format-title": "11.0.0",
158
155
  "@directus/extensions-registry": "2.0.6",
156
+ "@directus/errors": "1.0.1",
159
157
  "@directus/extensions-sdk": "12.1.4",
158
+ "@directus/format-title": "11.0.0",
160
159
  "@directus/memory": "2.0.6",
160
+ "@directus/extensions": "2.0.6",
161
161
  "@directus/pressure": "2.0.5",
162
162
  "@directus/schema": "12.1.1",
163
163
  "@directus/specs": "11.1.0",
164
164
  "@directus/storage": "11.0.1",
165
165
  "@directus/storage-driver-azure": "11.1.2",
166
- "@directus/storage-driver-cloudinary": "11.1.2",
167
166
  "@directus/storage-driver-gcs": "11.1.2",
168
- "@directus/storage-driver-supabase": "2.1.2",
167
+ "@directus/storage-driver-cloudinary": "11.1.2",
169
168
  "@directus/storage-driver-local": "11.0.1",
170
- "@directus/system-data": "2.1.2",
169
+ "@directus/storage-driver-supabase": "2.1.2",
171
170
  "@directus/storage-driver-s3": "11.0.5",
171
+ "@directus/system-data": "2.1.2",
172
172
  "@directus/utils": "12.0.5",
173
173
  "@directus/validation": "1.0.5",
174
- "directus": "11.3.1"
174
+ "directus": "11.3.3"
175
175
  },
176
176
  "devDependencies": {
177
177
  "@ngneat/falso": "7.2.0",
@@ -213,9 +213,9 @@
213
213
  "knex-mock-client": "3.0.2",
214
214
  "typescript": "5.6.3",
215
215
  "vitest": "2.1.2",
216
- "@directus/tsconfig": "2.0.0",
217
216
  "@directus/random": "1.0.0",
218
- "@directus/types": "12.2.2"
217
+ "@directus/types": "12.2.2",
218
+ "@directus/tsconfig": "2.0.0"
219
219
  },
220
220
  "optionalDependencies": {
221
221
  "@keyv/redis": "3.0.1",