@directus/api 19.1.1 → 19.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.
Files changed (48) hide show
  1. package/dist/controllers/users.js +1 -0
  2. package/dist/controllers/utils.js +7 -5
  3. package/dist/database/helpers/index.d.ts +1 -1
  4. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +1 -0
  5. package/dist/database/helpers/schema/dialects/cockroachdb.js +13 -0
  6. package/dist/database/helpers/schema/dialects/mssql.d.ts +1 -0
  7. package/dist/database/helpers/schema/dialects/mssql.js +9 -0
  8. package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
  9. package/dist/database/helpers/schema/dialects/mysql.js +17 -0
  10. package/dist/database/helpers/schema/dialects/oracle.d.ts +1 -0
  11. package/dist/database/helpers/schema/dialects/oracle.js +9 -0
  12. package/dist/database/helpers/schema/dialects/postgres.d.ts +4 -0
  13. package/dist/database/helpers/schema/dialects/postgres.js +14 -0
  14. package/dist/database/helpers/schema/dialects/sqlite.d.ts +1 -0
  15. package/dist/database/helpers/schema/dialects/sqlite.js +9 -0
  16. package/dist/database/helpers/schema/index.d.ts +3 -3
  17. package/dist/database/helpers/schema/index.js +3 -3
  18. package/dist/database/helpers/schema/types.d.ts +4 -0
  19. package/dist/database/helpers/schema/types.js +6 -0
  20. package/dist/middleware/graphql.js +5 -1
  21. package/dist/services/extensions.js +11 -8
  22. package/dist/services/graphql/index.js +15 -11
  23. package/dist/services/graphql/utils/sanitize-gql-schema.d.ts +8 -0
  24. package/dist/services/graphql/utils/sanitize-gql-schema.js +80 -0
  25. package/dist/services/roles.d.ts +1 -0
  26. package/dist/services/roles.js +200 -11
  27. package/dist/services/users.d.ts +1 -1
  28. package/dist/services/users.js +86 -17
  29. package/dist/telemetry/lib/get-report.js +22 -10
  30. package/dist/telemetry/types/report.d.ts +12 -0
  31. package/dist/telemetry/utils/check-increased-user-limits.d.ts +7 -0
  32. package/dist/telemetry/utils/check-increased-user-limits.js +22 -0
  33. package/dist/telemetry/utils/get-extension-count.d.ts +9 -0
  34. package/dist/telemetry/utils/get-extension-count.js +19 -0
  35. package/dist/telemetry/utils/get-field-count.d.ts +6 -0
  36. package/dist/telemetry/utils/get-field-count.js +12 -0
  37. package/dist/telemetry/utils/get-item-count.d.ts +10 -6
  38. package/dist/telemetry/utils/get-item-count.js +13 -9
  39. package/dist/telemetry/utils/get-role-counts-by-roles.d.ts +6 -0
  40. package/dist/telemetry/utils/get-role-counts-by-roles.js +27 -0
  41. package/dist/telemetry/utils/get-role-counts-by-users.d.ts +11 -0
  42. package/dist/telemetry/utils/get-role-counts-by-users.js +34 -0
  43. package/dist/telemetry/utils/get-user-count.d.ts +3 -2
  44. package/dist/telemetry/utils/get-user-count.js +7 -4
  45. package/dist/telemetry/utils/get-user-counts-by-roles.d.ts +7 -0
  46. package/dist/telemetry/utils/get-user-counts-by-roles.js +35 -0
  47. package/dist/telemetry/utils/get-user-item-count.js +4 -2
  48. package/package.json +28 -28
@@ -1,10 +1,16 @@
1
- import { ForbiddenError, InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
1
+ import { InvalidPayloadError, UnprocessableContentError } from '@directus/errors';
2
2
  import { getMatch } from 'ip-matching';
3
+ import { checkIncreasedUserLimits } from '../telemetry/utils/check-increased-user-limits.js';
4
+ import { getRoleCountsByUsers } from '../telemetry/utils/get-role-counts-by-users.js';
5
+ import {} from '../telemetry/utils/get-user-count.js';
6
+ import { getUserCountsByRoles } from '../telemetry/utils/get-user-counts-by-roles.js';
3
7
  import { transaction } from '../utils/transaction.js';
4
8
  import { ItemsService } from './items.js';
5
9
  import { PermissionsService } from './permissions/index.js';
6
10
  import { PresetsService } from './presets.js';
7
11
  import { UsersService } from './users.js';
12
+ import { shouldClearCache } from '../utils/should-clear-cache.js';
13
+ import { omit } from 'lodash-es';
8
14
  export class RolesService extends ItemsService {
9
15
  constructor(options) {
10
16
  super('directus_roles', options);
@@ -24,8 +30,9 @@ export class RolesService extends ItemsService {
24
30
  }
25
31
  async checkForOtherAdminUsers(key, users) {
26
32
  const role = await this.knex.select('admin_access').from('directus_roles').where('id', '=', key).first();
33
+ // No-op if role doesn't exist
27
34
  if (!role)
28
- throw new ForbiddenError();
35
+ return;
29
36
  const usersBefore = (await this.knex.select('id').from('directus_users').where('role', '=', key)).map((user) => user.id);
30
37
  const usersAdded = [];
31
38
  const usersUpdated = [];
@@ -151,44 +158,211 @@ export class RolesService extends ItemsService {
151
158
  });
152
159
  }
153
160
  }
161
+ getRoleAccessType(data) {
162
+ if ('admin_access' in data && data['admin_access'] === true) {
163
+ return 'admin';
164
+ }
165
+ else if (('app_access' in data && data['app_access'] === true) || 'app_access' in data === false) {
166
+ return 'app';
167
+ }
168
+ else {
169
+ return 'api';
170
+ }
171
+ }
154
172
  async createOne(data, opts) {
155
173
  this.assertValidIpAccess(data);
174
+ const increasedCounts = {
175
+ admin: 0,
176
+ app: 0,
177
+ api: 0,
178
+ };
179
+ const existingIds = [];
180
+ if ('users' in data) {
181
+ const type = this.getRoleAccessType(data);
182
+ increasedCounts[type] += data['users'].length;
183
+ for (const user of data['users']) {
184
+ if (typeof user === 'string') {
185
+ existingIds.push(user);
186
+ }
187
+ else if (typeof user === 'object' && 'id' in user) {
188
+ existingIds.push(user['id']);
189
+ }
190
+ }
191
+ }
192
+ await checkIncreasedUserLimits(this.knex, increasedCounts, existingIds);
156
193
  return super.createOne(data, opts);
157
194
  }
158
195
  async createMany(data, opts) {
196
+ const increasedCounts = {
197
+ admin: 0,
198
+ app: 0,
199
+ api: 0,
200
+ };
201
+ const existingIds = [];
159
202
  for (const partialItem of data) {
160
203
  this.assertValidIpAccess(partialItem);
204
+ if ('users' in partialItem) {
205
+ const type = this.getRoleAccessType(partialItem);
206
+ increasedCounts[type] += partialItem['users'].length;
207
+ for (const user of partialItem['users']) {
208
+ if (typeof user === 'string') {
209
+ existingIds.push(user);
210
+ }
211
+ else if (typeof user === 'object' && 'id' in user) {
212
+ existingIds.push(user['id']);
213
+ }
214
+ }
215
+ }
161
216
  }
217
+ await checkIncreasedUserLimits(this.knex, increasedCounts, existingIds);
162
218
  return super.createMany(data, opts);
163
219
  }
164
220
  async updateOne(key, data, opts) {
165
221
  this.assertValidIpAccess(data);
166
222
  try {
223
+ const increasedCounts = {
224
+ admin: 0,
225
+ app: 0,
226
+ api: 0,
227
+ };
228
+ let increasedUsers = 0;
229
+ const existingIds = [];
230
+ let existingRole = await this.knex
231
+ .count('directus_users.id', { as: 'count' })
232
+ .select('directus_roles.admin_access', 'directus_roles.app_access')
233
+ .from('directus_users')
234
+ .where('directus_roles.id', '=', key)
235
+ .andWhere('directus_users.status', '=', 'active')
236
+ .leftJoin('directus_roles', 'directus_users.role', '=', 'directus_roles.id')
237
+ .groupBy('directus_roles.admin_access', 'directus_roles.app_access')
238
+ .first();
239
+ if (!existingRole) {
240
+ try {
241
+ const role = (await this.knex
242
+ .select('admin_access', 'app_access')
243
+ .from('directus_roles')
244
+ .where('id', '=', key)
245
+ .first()) ?? { admin_access: null, app_access: null };
246
+ existingRole = { count: 0, ...role };
247
+ }
248
+ catch {
249
+ existingRole = { count: 0, admin_access: null, app_access: null };
250
+ }
251
+ }
167
252
  if ('users' in data) {
168
253
  await this.checkForOtherAdminUsers(key, data['users']);
254
+ const users = data['users'];
255
+ if (Array.isArray(users)) {
256
+ increasedUsers = users.length - Number(existingRole.count);
257
+ for (const user of users) {
258
+ if (typeof user === 'string') {
259
+ existingIds.push(user);
260
+ }
261
+ else if (typeof user === 'object' && 'id' in user) {
262
+ existingIds.push(user['id']);
263
+ }
264
+ }
265
+ }
266
+ else {
267
+ increasedUsers += users.create.length;
268
+ increasedUsers -= users.delete.length;
269
+ const userIds = [];
270
+ for (const user of users.update) {
271
+ if ('status' in user) {
272
+ // account for users being activated and deactivated
273
+ if (user['status'] === 'active') {
274
+ increasedUsers++;
275
+ }
276
+ else {
277
+ increasedUsers--;
278
+ }
279
+ }
280
+ userIds.push(user.id);
281
+ }
282
+ try {
283
+ const existingCounts = await getRoleCountsByUsers(this.knex, userIds);
284
+ if (existingRole.admin_access) {
285
+ increasedUsers += existingCounts.app + existingCounts.api;
286
+ }
287
+ else if (existingRole.app_access) {
288
+ increasedUsers += existingCounts.admin + existingCounts.api;
289
+ }
290
+ else {
291
+ increasedUsers += existingCounts.admin + existingCounts.app;
292
+ }
293
+ }
294
+ catch {
295
+ // ignore failed user call
296
+ }
297
+ }
298
+ }
299
+ let isAccessChanged = false;
300
+ let accessType = 'api';
301
+ if ('app_access' in data) {
302
+ if (data['app_access'] === true) {
303
+ accessType = 'app';
304
+ if (!existingRole.app_access)
305
+ isAccessChanged = true;
306
+ }
307
+ else if (existingRole.app_access) {
308
+ isAccessChanged = true;
309
+ }
310
+ }
311
+ else if (existingRole.app_access) {
312
+ accessType = 'app';
313
+ }
314
+ if ('admin_access' in data) {
315
+ if (data['admin_access'] === true) {
316
+ accessType = 'admin';
317
+ if (!existingRole.admin_access)
318
+ isAccessChanged = true;
319
+ }
320
+ else if (existingRole.admin_access) {
321
+ isAccessChanged = true;
322
+ }
323
+ }
324
+ else if (existingRole.admin_access) {
325
+ accessType = 'admin';
326
+ }
327
+ if (isAccessChanged) {
328
+ increasedCounts[accessType] += Number(existingRole.count);
169
329
  }
330
+ increasedCounts[accessType] += increasedUsers;
331
+ await checkIncreasedUserLimits(this.knex, increasedCounts, existingIds);
170
332
  }
171
333
  catch (err) {
172
334
  (opts || (opts = {})).preMutationError = err;
173
335
  }
174
336
  return super.updateOne(key, data, opts);
175
337
  }
176
- async updateBatch(data, opts) {
338
+ async updateBatch(data, opts = {}) {
177
339
  for (const partialItem of data) {
178
340
  this.assertValidIpAccess(partialItem);
179
341
  }
180
342
  const primaryKeyField = this.schema.collections[this.collection].primary;
181
- const keys = data.map((item) => item[primaryKeyField]);
182
- const setsToNoAdmin = data.some((item) => item['admin_access'] === false);
343
+ if (!opts.mutationTracker) {
344
+ opts.mutationTracker = this.createMutationTracker();
345
+ }
346
+ const keys = [];
183
347
  try {
184
- if (setsToNoAdmin) {
185
- await this.checkForOtherAdminRoles(keys);
186
- }
348
+ await transaction(this.knex, async (trx) => {
349
+ const service = new RolesService({
350
+ accountability: this.accountability,
351
+ knex: trx,
352
+ schema: this.schema,
353
+ });
354
+ for (const item of data) {
355
+ const combinedOpts = Object.assign({ autoPurgeCache: false }, opts);
356
+ keys.push(await service.updateOne(item[primaryKeyField], omit(item, primaryKeyField), combinedOpts));
357
+ }
358
+ });
187
359
  }
188
- catch (err) {
189
- (opts || (opts = {})).preMutationError = err;
360
+ finally {
361
+ if (shouldClearCache(this.cache, opts, this.collection)) {
362
+ await this.cache.clear();
363
+ }
190
364
  }
191
- return super.updateBatch(data, opts);
365
+ return keys;
192
366
  }
193
367
  async updateMany(keys, data, opts) {
194
368
  this.assertValidIpAccess(data);
@@ -196,6 +370,21 @@ export class RolesService extends ItemsService {
196
370
  if ('admin_access' in data && data['admin_access'] === false) {
197
371
  await this.checkForOtherAdminRoles(keys);
198
372
  }
373
+ if ('admin_access' in data || 'app_access' in data) {
374
+ const existingCounts = await getUserCountsByRoles(this.knex, keys);
375
+ const increasedCounts = {
376
+ admin: 0,
377
+ app: 0,
378
+ api: 0,
379
+ };
380
+ const type = this.getRoleAccessType(data);
381
+ for (const [existingType, existingCount] of Object.entries(existingCounts)) {
382
+ if (existingType === type)
383
+ continue;
384
+ increasedCounts[type] += existingCount;
385
+ }
386
+ await checkIncreasedUserLimits(this.knex, increasedCounts);
387
+ }
199
388
  }
200
389
  catch (err) {
201
390
  (opts || (opts = {})).preMutationError = err;
@@ -23,7 +23,7 @@ export declare class UsersService extends ItemsService {
23
23
  */
24
24
  private getUserByEmail;
25
25
  /**
26
- * Create url for inviting users
26
+ * Create URL for inviting users
27
27
  */
28
28
  private inviteUrl;
29
29
  /**
@@ -1,13 +1,17 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { ForbiddenError, InvalidPayloadError, RecordNotUniqueError, UnprocessableContentError } from '@directus/errors';
3
- import { getSimpleHash, toArray, validatePayload } from '@directus/utils';
3
+ import { getSimpleHash, toArray, toBoolean, validatePayload } from '@directus/utils';
4
4
  import { FailedValidationError, joiValidationErrorItemToErrorExtensions } from '@directus/validation';
5
5
  import Joi from 'joi';
6
6
  import jwt from 'jsonwebtoken';
7
- import { cloneDeep, isEmpty } from 'lodash-es';
7
+ import { cloneDeep, isEmpty, mergeWith } from 'lodash-es';
8
8
  import { performance } from 'perf_hooks';
9
9
  import getDatabase from '../database/index.js';
10
10
  import { useLogger } from '../logger.js';
11
+ import { checkIncreasedUserLimits } from '../telemetry/utils/check-increased-user-limits.js';
12
+ import { getRoleCountsByRoles } from '../telemetry/utils/get-role-counts-by-roles.js';
13
+ import { getRoleCountsByUsers } from '../telemetry/utils/get-role-counts-by-users.js';
14
+ import {} from '../telemetry/utils/get-user-count.js';
11
15
  import { getSecret } from '../utils/get-secret.js';
12
16
  import isUrlAllowed from '../utils/is-url-allowed.js';
13
17
  import { verifyJWT } from '../utils/jwt.js';
@@ -126,14 +130,14 @@ export class UsersService extends ItemsService {
126
130
  .first();
127
131
  }
128
132
  /**
129
- * Create url for inviting users
133
+ * Create URL for inviting users
130
134
  */
131
135
  inviteUrl(email, url) {
132
136
  const payload = { email, scope: 'invite' };
133
137
  const token = jwt.sign(payload, getSecret(), { expiresIn: '7d', issuer: 'directus' });
134
- const inviteURL = url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'accept-invite');
135
- inviteURL.setQuery('token', token);
136
- return inviteURL.toString();
138
+ return (url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'accept-invite'))
139
+ .setQuery('token', token)
140
+ .toString();
137
141
  }
138
142
  /**
139
143
  * Validate array of emails. Intended to be used with create/update users
@@ -164,6 +168,7 @@ export class UsersService extends ItemsService {
164
168
  async createMany(data, opts) {
165
169
  const emails = data['map']((payload) => payload['email']).filter((email) => email);
166
170
  const passwords = data['map']((payload) => payload['password']).filter((password) => password);
171
+ const roles = data['map']((payload) => payload['role']).filter((role) => role);
167
172
  try {
168
173
  if (emails.length) {
169
174
  this.validateEmail(emails);
@@ -172,6 +177,33 @@ export class UsersService extends ItemsService {
172
177
  if (passwords.length) {
173
178
  await this.checkPasswordPolicy(passwords);
174
179
  }
180
+ if (roles.length) {
181
+ const increasedCounts = {
182
+ admin: 0,
183
+ app: 0,
184
+ api: 0,
185
+ };
186
+ const existingRoles = [];
187
+ for (const role of roles) {
188
+ if (typeof role === 'object') {
189
+ if ('admin_access' in role && role['admin_access'] === true) {
190
+ increasedCounts.admin++;
191
+ }
192
+ else if ('app_access' in role && role['app_access'] === true) {
193
+ increasedCounts.app++;
194
+ }
195
+ else {
196
+ increasedCounts.api++;
197
+ }
198
+ }
199
+ else {
200
+ existingRoles.push(role);
201
+ }
202
+ }
203
+ const existingRoleCounts = await getRoleCountsByRoles(this.knex, existingRoles);
204
+ mergeWith(increasedCounts, existingRoleCounts, (x, y) => x + y);
205
+ await checkIncreasedUserLimits(this.knex, increasedCounts);
206
+ }
175
207
  }
176
208
  catch (err) {
177
209
  (opts || (opts = {})).preMutationError = err;
@@ -226,7 +258,11 @@ export class UsersService extends ItemsService {
226
258
  const role = data['role']?.id ?? data['role'];
227
259
  let newRole;
228
260
  if (typeof role === 'string') {
229
- newRole = await this.knex.select('admin_access').from('directus_roles').where('id', role).first();
261
+ newRole = await this.knex
262
+ .select('admin_access', 'app_access')
263
+ .from('directus_roles')
264
+ .where('id', role)
265
+ .first();
230
266
  }
231
267
  else {
232
268
  newRole = role;
@@ -234,10 +270,35 @@ export class UsersService extends ItemsService {
234
270
  if (!newRole?.admin_access) {
235
271
  await this.checkRemainingAdminExistence(keys);
236
272
  }
273
+ if (newRole) {
274
+ const existingCounts = await getRoleCountsByUsers(this.knex, keys);
275
+ const increasedCounts = {
276
+ admin: 0,
277
+ app: 0,
278
+ api: 0,
279
+ };
280
+ if (toBoolean(newRole.admin_access)) {
281
+ increasedCounts.admin = keys.length - existingCounts.admin;
282
+ }
283
+ else if (toBoolean(newRole.app_access)) {
284
+ increasedCounts.app = keys.length - existingCounts.app;
285
+ }
286
+ else {
287
+ increasedCounts.api = keys.length - existingCounts.api;
288
+ }
289
+ await checkIncreasedUserLimits(this.knex, increasedCounts);
290
+ }
291
+ }
292
+ if (data['role'] === null) {
293
+ await checkIncreasedUserLimits(this.knex, { admin: 0, app: 0, api: 1 });
237
294
  }
238
295
  if (data['status'] !== undefined && data['status'] !== 'active') {
239
296
  await this.checkRemainingActiveAdmin(keys);
240
297
  }
298
+ if (data['status'] === 'active') {
299
+ const increasedCounts = await getRoleCountsByUsers(this.knex, keys, { inactiveUsers: true });
300
+ await checkIncreasedUserLimits(this.knex, increasedCounts);
301
+ }
241
302
  if (data['email']) {
242
303
  if (keys.length > 1) {
243
304
  throw new RecordNotUniqueError({
@@ -314,7 +375,7 @@ export class UsersService extends ItemsService {
314
375
  const opts = {};
315
376
  try {
316
377
  if (url && isUrlAllowed(url, env['USER_INVITE_URL_ALLOW_LIST']) === false) {
317
- throw new InvalidPayloadError({ reason: `Url "${url}" can't be used to invite users` });
378
+ throw new InvalidPayloadError({ reason: `URL "${url}" can't be used to invite users` });
318
379
  }
319
380
  }
320
381
  catch (err) {
@@ -373,6 +434,12 @@ export class UsersService extends ItemsService {
373
434
  await service.updateOne(user.id, { password, status: 'active' });
374
435
  }
375
436
  async registerUser(input) {
437
+ if (input.verification_url &&
438
+ isUrlAllowed(input.verification_url, env['USER_REGISTER_URL_ALLOW_LIST']) === false) {
439
+ throw new InvalidPayloadError({
440
+ reason: `URL "${input.verification_url}" can't be used to verify registered users`,
441
+ });
442
+ }
376
443
  const STALL_TIME = env['REGISTER_STALL_TIME'];
377
444
  const timeStart = performance.now();
378
445
  const serviceOptions = { accountability: this.accountability, schema: this.schema };
@@ -424,9 +491,11 @@ export class UsersService extends ItemsService {
424
491
  expiresIn: env['EMAIL_VERIFICATION_TOKEN_TTL'],
425
492
  issuer: 'directus',
426
493
  });
427
- const verificationURL = new Url(env['PUBLIC_URL'])
428
- .addPath('users', 'register', 'verify-email')
429
- .setQuery('token', token);
494
+ const verificationUrl = (input.verification_url
495
+ ? new Url(input.verification_url)
496
+ : new Url(env['PUBLIC_URL']).addPath('users', 'register', 'verify-email'))
497
+ .setQuery('token', token)
498
+ .toString();
430
499
  mailService
431
500
  .send({
432
501
  to: input.email,
@@ -434,7 +503,7 @@ export class UsersService extends ItemsService {
434
503
  template: {
435
504
  name: 'user-registration',
436
505
  data: {
437
- url: verificationURL.toString(),
506
+ url: verificationUrl,
438
507
  email: input.email,
439
508
  first_name,
440
509
  last_name,
@@ -467,7 +536,7 @@ export class UsersService extends ItemsService {
467
536
  throw new ForbiddenError();
468
537
  }
469
538
  if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) {
470
- throw new InvalidPayloadError({ reason: `Url "${url}" can't be used to reset passwords` });
539
+ throw new InvalidPayloadError({ reason: `URL "${url}" can't be used to reset passwords` });
471
540
  }
472
541
  const mailService = new MailService({
473
542
  schema: this.schema,
@@ -476,9 +545,9 @@ export class UsersService extends ItemsService {
476
545
  });
477
546
  const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
478
547
  const token = jwt.sign(payload, getSecret(), { expiresIn: '1d', issuer: 'directus' });
479
- const acceptURL = url
480
- ? new Url(url).setQuery('token', token).toString()
481
- : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
548
+ const acceptUrl = (url ? new Url(url) : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password'))
549
+ .setQuery('token', token)
550
+ .toString();
482
551
  const subjectLine = subject ? subject : 'Password Reset Request';
483
552
  mailService
484
553
  .send({
@@ -487,7 +556,7 @@ export class UsersService extends ItemsService {
487
556
  template: {
488
557
  name: 'password-reset',
489
558
  data: {
490
- url: acceptURL,
559
+ url: acceptUrl,
491
560
  email: user.email,
492
561
  },
493
562
  },
@@ -1,16 +1,21 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { version } from 'directus/version';
3
+ import { getHelpers } from '../../database/helpers/index.js';
3
4
  import { getDatabase, getDatabaseClient } from '../../database/index.js';
5
+ import { getExtensionCount } from '../utils/get-extension-count.js';
6
+ import { getFieldCount } from '../utils/get-field-count.js';
4
7
  import { getItemCount } from '../utils/get-item-count.js';
5
8
  import { getUserCount } from '../utils/get-user-count.js';
6
9
  import { getUserItemCount } from '../utils/get-user-item-count.js';
7
- const basicCountCollections = [
8
- 'directus_dashboards',
9
- 'directus_extensions',
10
- 'directus_files',
11
- 'directus_flows',
12
- 'directus_roles',
13
- 'directus_shares',
10
+ const basicCountTasks = [
11
+ { collection: 'directus_dashboards' },
12
+ { collection: 'directus_files' },
13
+ {
14
+ collection: 'directus_flows',
15
+ where: ['status', '=', 'active'],
16
+ },
17
+ { collection: 'directus_roles' },
18
+ { collection: 'directus_shares' },
14
19
  ];
15
20
  /**
16
21
  * Create a telemetry report about the anonymous usage of the current installation
@@ -18,17 +23,20 @@ const basicCountCollections = [
18
23
  export const getReport = async () => {
19
24
  const db = getDatabase();
20
25
  const env = useEnv();
21
- const [basicCounts, userCounts, userItemCount] = await Promise.all([
22
- getItemCount(db, basicCountCollections),
26
+ const helpers = getHelpers(db);
27
+ const [basicCounts, userCounts, userItemCount, fieldsCounts, extensionsCounts, databaseSize] = await Promise.all([
28
+ getItemCount(db, basicCountTasks),
23
29
  getUserCount(db),
24
30
  getUserItemCount(db),
31
+ getFieldCount(db),
32
+ getExtensionCount(db),
33
+ helpers.schema.getDatabaseSize(),
25
34
  ]);
26
35
  return {
27
36
  url: env['PUBLIC_URL'],
28
37
  version: version,
29
38
  database: getDatabaseClient(),
30
39
  dashboards: basicCounts.directus_dashboards,
31
- extensions: basicCounts.directus_extensions,
32
40
  files: basicCounts.directus_files,
33
41
  flows: basicCounts.directus_flows,
34
42
  roles: basicCounts.directus_roles,
@@ -38,5 +46,9 @@ export const getReport = async () => {
38
46
  api_users: userCounts.api,
39
47
  collections: userItemCount.collections,
40
48
  items: userItemCount.items,
49
+ fields_max: fieldsCounts.max,
50
+ fields_total: fieldsCounts.total,
51
+ extensions: extensionsCounts.totalEnabled,
52
+ database_size: databaseSize ?? 0,
41
53
  };
42
54
  };
@@ -55,4 +55,16 @@ export interface TelemetryReport {
55
55
  * Number of shares in the system
56
56
  */
57
57
  shares: number;
58
+ /**
59
+ * Maximum number of fields in a collection
60
+ */
61
+ fields_max: number;
62
+ /**
63
+ * Number of fields in the system
64
+ */
65
+ fields_total: number;
66
+ /**
67
+ * Size of the database in bytes
68
+ */
69
+ database_size: number;
58
70
  }
@@ -0,0 +1,7 @@
1
+ import type { Knex } from 'knex';
2
+ import { type AccessTypeCount } from './get-user-count.js';
3
+ import type { PrimaryKey } from '@directus/types';
4
+ /**
5
+ * Ensure that user limits are not reached
6
+ */
7
+ export declare function checkIncreasedUserLimits(db: Knex, increasedUserCounts: AccessTypeCount, ignoreIds?: PrimaryKey[]): Promise<void>;
@@ -0,0 +1,22 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { LimitExceededError } from '@directus/errors';
3
+ import { getUserCount } from './get-user-count.js';
4
+ const env = useEnv();
5
+ /**
6
+ * Ensure that user limits are not reached
7
+ */
8
+ export async function checkIncreasedUserLimits(db, increasedUserCounts, ignoreIds = []) {
9
+ if (!increasedUserCounts.admin && !increasedUserCounts.app && !increasedUserCounts.api)
10
+ return;
11
+ const userCounts = await getUserCount(db, ignoreIds);
12
+ if (increasedUserCounts.admin > 0 &&
13
+ increasedUserCounts.admin + userCounts.admin > Number(env['USERS_ADMIN_ACCESS_LIMIT'])) {
14
+ throw new LimitExceededError({ category: 'Active Admin users' });
15
+ }
16
+ if (increasedUserCounts.app > 0 && increasedUserCounts.app + userCounts.app > Number(env['USERS_APP_ACCESS_LIMIT'])) {
17
+ throw new LimitExceededError({ category: 'Active App users' });
18
+ }
19
+ if (increasedUserCounts.api > 0 && increasedUserCounts.api + userCounts.api > Number(env['USERS_API_ACCESS_LIMIT'])) {
20
+ throw new LimitExceededError({ category: 'Active API users' });
21
+ }
22
+ }
@@ -0,0 +1,9 @@
1
+ import { type Knex } from 'knex';
2
+ export interface ExtensionCount {
3
+ /**
4
+ * Total count of enabled extensions excluding Bundle-Parents,
5
+ * meaning a Bundle extensions with one extension inside of it counts as one.
6
+ */
7
+ totalEnabled: number;
8
+ }
9
+ export declare const getExtensionCount: (db: Knex) => Promise<ExtensionCount>;
@@ -0,0 +1,19 @@
1
+ import {} from 'knex';
2
+ import { ExtensionsService } from '../../services/extensions.js';
3
+ import { getSchema } from '../../utils/get-schema.js';
4
+ export const getExtensionCount = async (db) => {
5
+ const extensionsService = new ExtensionsService({
6
+ knex: db,
7
+ schema: await getSchema({ database: db }),
8
+ });
9
+ const extensions = await extensionsService.readAll();
10
+ let totalEnabled = 0;
11
+ for (const extension of extensions) {
12
+ if (extension.meta.enabled && extension.schema && extension.schema.type !== 'bundle') {
13
+ totalEnabled++;
14
+ }
15
+ }
16
+ return {
17
+ totalEnabled,
18
+ };
19
+ };
@@ -0,0 +1,6 @@
1
+ import { type Knex } from 'knex';
2
+ export interface FieldCount {
3
+ max: number;
4
+ total: number;
5
+ }
6
+ export declare const getFieldCount: (db: Knex) => Promise<FieldCount>;
@@ -0,0 +1,12 @@
1
+ import {} from 'knex';
2
+ export const getFieldCount = async (db) => {
3
+ const query = (await db
4
+ .max({ max: 'field_count' })
5
+ .sum({ total: 'field_count' })
6
+ .from(db.select('collection').count('* as field_count').from('directus_fields').groupBy('collection').as('inner'))
7
+ .first());
8
+ return {
9
+ max: query?.max ? Number(query.max) : 0,
10
+ total: query?.total ? Number(query.total) : 0,
11
+ };
12
+ };