@directus/api 14.1.1 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/app.js +3 -3
  2. package/dist/auth/drivers/oauth2.js +2 -3
  3. package/dist/auth/drivers/openid.js +2 -3
  4. package/dist/cli/commands/schema/apply.js +44 -33
  5. package/dist/cli/index.js +2 -2
  6. package/dist/database/index.d.ts +2 -1
  7. package/dist/database/index.js +2 -1
  8. package/dist/database/system-data/fields/settings.yaml +4 -0
  9. package/dist/database/system-data/fields/users.yaml +4 -0
  10. package/dist/database/system-data/relations/relations.yaml +4 -0
  11. package/dist/emitter.d.ts +1 -0
  12. package/dist/emitter.js +2 -1
  13. package/dist/env.d.ts +1 -0
  14. package/dist/env.js +6 -0
  15. package/dist/extensions/lib/get-shared-deps-mapping.js +1 -1
  16. package/dist/extensions/lib/sandbox/register/route.d.ts +1 -0
  17. package/dist/extensions/manager.d.ts +5 -0
  18. package/dist/extensions/manager.js +42 -16
  19. package/dist/logger.d.ts +2 -1
  20. package/dist/logger.js +1 -0
  21. package/dist/middleware/rate-limiter-ip.js +14 -11
  22. package/dist/redis/create-redis.d.ts +7 -0
  23. package/dist/redis/create-redis.js +12 -0
  24. package/dist/redis/index.d.ts +2 -0
  25. package/dist/redis/index.js +2 -0
  26. package/dist/redis/use-redis.d.ts +16 -0
  27. package/dist/redis/use-redis.js +22 -0
  28. package/dist/server.d.ts +2 -0
  29. package/dist/services/extensions.js +1 -1
  30. package/dist/services/graphql/index.js +50 -15
  31. package/dist/services/payload.js +3 -3
  32. package/dist/services/server.js +2 -3
  33. package/dist/services/specifications.js +3 -3
  34. package/dist/services/users.js +6 -6
  35. package/dist/telemetry/index.d.ts +4 -0
  36. package/dist/telemetry/index.js +4 -0
  37. package/dist/telemetry/lib/get-report.d.ts +5 -0
  38. package/dist/telemetry/lib/get-report.js +42 -0
  39. package/dist/telemetry/lib/init-telemetry.d.ts +11 -0
  40. package/dist/telemetry/lib/init-telemetry.js +30 -0
  41. package/dist/telemetry/lib/send-report.d.ts +5 -0
  42. package/dist/telemetry/lib/send-report.js +23 -0
  43. package/dist/telemetry/lib/track.d.ts +10 -0
  44. package/dist/telemetry/lib/track.js +31 -0
  45. package/dist/telemetry/types/report.d.ts +58 -0
  46. package/dist/telemetry/types/report.js +1 -0
  47. package/dist/telemetry/utils/get-item-count.d.ts +26 -0
  48. package/dist/telemetry/utils/get-item-count.js +36 -0
  49. package/dist/telemetry/utils/get-random-wait-time.d.ts +5 -0
  50. package/dist/telemetry/utils/get-random-wait-time.js +5 -0
  51. package/dist/telemetry/utils/get-user-count.d.ts +7 -0
  52. package/dist/telemetry/utils/get-user-count.js +30 -0
  53. package/dist/telemetry/utils/get-user-item-count.d.ts +13 -0
  54. package/dist/telemetry/utils/get-user-item-count.js +18 -0
  55. package/dist/utils/apply-query.js +13 -2
  56. package/dist/utils/get-cache-key.js +1 -1
  57. package/dist/utils/get-ip-from-req.d.ts +1 -1
  58. package/dist/utils/get-ip-from-req.js +1 -1
  59. package/dist/utils/get-permissions.js +0 -3
  60. package/dist/utils/get-snapshot-diff.js +2 -1
  61. package/dist/utils/get-snapshot.js +1 -1
  62. package/dist/utils/get-versioned-hash.js +1 -1
  63. package/dist/utils/md.d.ts +1 -1
  64. package/dist/utils/md.js +3 -2
  65. package/dist/utils/merge-permissions.js +19 -11
  66. package/dist/utils/validate-query.js +1 -0
  67. package/dist/utils/validate-snapshot.js +3 -3
  68. package/dist/websocket/controllers/base.d.ts +2 -0
  69. package/dist/websocket/controllers/graphql.d.ts +2 -0
  70. package/dist/websocket/controllers/graphql.js +1 -1
  71. package/dist/websocket/controllers/index.d.ts +2 -0
  72. package/dist/websocket/controllers/rest.d.ts +2 -0
  73. package/dist/websocket/types.d.ts +3 -1
  74. package/package.json +108 -109
  75. package/dist/utils/package.d.ts +0 -2
  76. package/dist/utils/package.js +0 -6
  77. package/dist/utils/telemetry.d.ts +0 -1
  78. package/dist/utils/telemetry.js +0 -23
@@ -0,0 +1,16 @@
1
+ import { Redis } from 'ioredis';
2
+ /**
3
+ * Memoization cache for useRedis
4
+ *
5
+ * @see {@link useRedis}
6
+ */
7
+ export declare const _cache: {
8
+ redis: Redis | undefined;
9
+ };
10
+ /**
11
+ * Access the globally shared Redis instance
12
+ * Creates new Redis instance on first invocation
13
+ *
14
+ * @returns Globally shared Redis instance
15
+ */
16
+ export declare const useRedis: () => Redis;
@@ -0,0 +1,22 @@
1
+ import { Redis } from 'ioredis';
2
+ import { createRedis } from './create-redis.js';
3
+ /**
4
+ * Memoization cache for useRedis
5
+ *
6
+ * @see {@link useRedis}
7
+ */
8
+ export const _cache = {
9
+ redis: undefined,
10
+ };
11
+ /**
12
+ * Access the globally shared Redis instance
13
+ * Creates new Redis instance on first invocation
14
+ *
15
+ * @returns Globally shared Redis instance
16
+ */
17
+ export const useRedis = () => {
18
+ if (_cache.redis)
19
+ return _cache.redis;
20
+ _cache.redis = createRedis();
21
+ return _cache.redis;
22
+ };
package/dist/server.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node/http.js" />
3
+ /// <reference types="pino-http" />
2
4
  import * as http from 'http';
3
5
  export declare let SERVER_ONLINE: boolean;
4
6
  export declare function createServer(): Promise<http.Server>;
@@ -46,7 +46,7 @@ export class ExtensionsService {
46
46
  throw new ForbiddenError();
47
47
  }
48
48
  const key = this.getKey(bundle, name);
49
- const schema = this.extensionsManager.getExtensions().find((extension) => extension.name === bundle ?? name);
49
+ const schema = this.extensionsManager.getExtensions().find((extension) => extension.name === (bundle ?? name));
50
50
  const meta = await this.extensionsItemService.readOne(key);
51
51
  const stitched = this.stitch(schema ? [schema] : [], [meta])[0];
52
52
  if (stitched)
@@ -126,7 +126,7 @@ export class GraphQLService {
126
126
  delete: { value: 'delete' },
127
127
  },
128
128
  });
129
- const { ReadCollectionTypes } = getReadableTypes();
129
+ const { ReadCollectionTypes, VersionCollectionTypes } = getReadableTypes();
130
130
  const { CreateCollectionTypes, UpdateCollectionTypes, DeleteCollectionTypes } = getWritableTypes();
131
131
  const scopeFilter = (collection) => {
132
132
  if (this.scope === 'items' && collection.collection.startsWith('directus_') === true)
@@ -158,6 +158,9 @@ export class GraphQLService {
158
158
  acc[`${collectionName}_by_id`] = ReadCollectionTypes[collection.collection].getResolver(`${collection.collection}_by_id`);
159
159
  acc[`${collectionName}_aggregated`] = ReadCollectionTypes[collection.collection].getResolver(`${collection.collection}_aggregated`);
160
160
  }
161
+ if (this.scope === 'items') {
162
+ acc[`${collectionName}_by_version`] = VersionCollectionTypes[collection.collection].getResolver(`${collection.collection}_by_version`);
163
+ }
161
164
  return acc;
162
165
  }, {}));
163
166
  }
@@ -221,6 +224,7 @@ export class GraphQLService {
221
224
  */
222
225
  function getTypes(action) {
223
226
  const CollectionTypes = {};
227
+ const VersionTypes = {};
224
228
  const CountFunctions = schemaComposer.createObjectTC({
225
229
  name: 'count_functions',
226
230
  fields: {
@@ -289,13 +293,19 @@ export class GraphQLService {
289
293
  type = new GraphQLNonNull(type);
290
294
  }
291
295
  if (collection.primary === field.field) {
292
- if (!field.defaultValue && !field.special.includes('uuid') && action === 'create') {
296
+ // permissions IDs need to be nullable https://github.com/directus/directus/issues/20509
297
+ if (collection.collection === 'directus_permissions') {
298
+ type = GraphQLID;
299
+ }
300
+ else if (!field.defaultValue && !field.special.includes('uuid') && action === 'create') {
293
301
  type = new GraphQLNonNull(GraphQLID);
294
302
  }
295
- else if (['create', 'update'].includes(action))
303
+ else if (['create', 'update'].includes(action)) {
296
304
  type = GraphQLID;
297
- else
305
+ }
306
+ else {
298
307
  type = new GraphQLNonNull(GraphQLID);
308
+ }
299
309
  }
300
310
  acc[field.field] = {
301
311
  type,
@@ -345,6 +355,9 @@ export class GraphQLService {
345
355
  return acc;
346
356
  }, {}),
347
357
  });
358
+ if (self.scope === 'items') {
359
+ VersionTypes[collection.collection] = CollectionTypes[collection.collection].clone(`version_${collection.collection}`);
360
+ }
348
361
  }
349
362
  for (const relation of schema[action].relations) {
350
363
  if (relation.related_collection) {
@@ -367,6 +380,16 @@ export class GraphQLService {
367
380
  },
368
381
  },
369
382
  });
383
+ if (self.scope === 'items') {
384
+ VersionTypes[relation.related_collection]?.addFields({
385
+ [relation.meta.one_field]: {
386
+ type: GraphQLJSON,
387
+ resolve: (obj, _, __, info) => {
388
+ return obj[info?.path?.key ?? relation.meta.one_field];
389
+ },
390
+ },
391
+ });
392
+ }
370
393
  }
371
394
  }
372
395
  else if (relation.meta?.one_allowed_collections && action === 'read') {
@@ -399,13 +422,13 @@ export class GraphQLService {
399
422
  });
400
423
  }
401
424
  }
402
- return { CollectionTypes };
425
+ return { CollectionTypes, VersionTypes };
403
426
  }
404
427
  /**
405
428
  * Create readable types and attach resolvers for each. Also prepares full filter argument structures
406
429
  */
407
430
  function getReadableTypes() {
408
- const { CollectionTypes: ReadCollectionTypes } = getTypes('read');
431
+ const { CollectionTypes: ReadCollectionTypes, VersionTypes: VersionCollectionTypes } = getTypes('read');
409
432
  const ReadableCollectionFilterTypes = {};
410
433
  const AggregatedFunctions = {};
411
434
  const AggregatedFields = {};
@@ -843,13 +866,6 @@ export class GraphQLService {
843
866
  },
844
867
  };
845
868
  }
846
- else {
847
- resolver.args = {
848
- version: {
849
- type: GraphQLString,
850
- },
851
- };
852
- }
853
869
  ReadCollectionTypes[collection.collection].addResolver(resolver);
854
870
  ReadCollectionTypes[collection.collection].addResolver({
855
871
  name: `${collection.collection}_aggregated`,
@@ -885,7 +901,6 @@ export class GraphQLService {
885
901
  type: ReadCollectionTypes[collection.collection],
886
902
  args: {
887
903
  id: new GraphQLNonNull(GraphQLID),
888
- version: GraphQLString,
889
904
  },
890
905
  resolve: async ({ info, context }) => {
891
906
  const result = await self.resolveQuery(info);
@@ -894,6 +909,23 @@ export class GraphQLService {
894
909
  },
895
910
  });
896
911
  }
912
+ if (self.scope === 'items') {
913
+ VersionCollectionTypes[collection.collection].addResolver({
914
+ name: `${collection.collection}_by_version`,
915
+ type: VersionCollectionTypes[collection.collection],
916
+ args: collection.singleton
917
+ ? { version: new GraphQLNonNull(GraphQLString) }
918
+ : {
919
+ version: new GraphQLNonNull(GraphQLString),
920
+ id: new GraphQLNonNull(GraphQLID),
921
+ },
922
+ resolve: async ({ info, context }) => {
923
+ const result = await self.resolveQuery(info);
924
+ context['data'] = result;
925
+ return result;
926
+ },
927
+ });
928
+ }
897
929
  const eventName = `${collection.collection}_mutated`;
898
930
  if (collection.collection in ReadCollectionTypes) {
899
931
  const subscriptionType = schemaComposer.createObjectTC({
@@ -973,7 +1005,7 @@ export class GraphQLService {
973
1005
  }
974
1006
  }
975
1007
  }
976
- return { ReadCollectionTypes, ReadableCollectionFilterTypes };
1008
+ return { ReadCollectionTypes, VersionCollectionTypes, ReadableCollectionFilterTypes };
977
1009
  }
978
1010
  function getWritableTypes() {
979
1011
  const { CollectionTypes: CreateCollectionTypes } = getTypes('create');
@@ -1135,6 +1167,9 @@ export class GraphQLService {
1135
1167
  if (collection.endsWith('_by_id') && collection in this.schema.collections === false) {
1136
1168
  collection = collection.slice(0, -6);
1137
1169
  }
1170
+ if (collection.endsWith('_by_version') && collection in this.schema.collections === false) {
1171
+ collection = collection.slice(0, -11);
1172
+ }
1138
1173
  }
1139
1174
  if (args['id']) {
1140
1175
  query.filter = {
@@ -1,13 +1,13 @@
1
+ import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
1
2
  import { parseJSON, toArray } from '@directus/utils';
2
3
  import { format, isValid, parseISO } from 'date-fns';
3
- import flat from 'flat';
4
+ import { unflatten } from 'flat';
4
5
  import Joi from 'joi';
5
6
  import { clone, cloneDeep, isNil, isObject, isPlainObject, omit, pick } from 'lodash-es';
6
7
  import { v4 as uuid } from 'uuid';
7
8
  import { parse as wktToGeoJSON } from 'wellknown';
8
9
  import { getHelpers } from '../database/helpers/index.js';
9
10
  import getDatabase from '../database/index.js';
10
- import { ForbiddenError, InvalidPayloadError } from '@directus/errors';
11
11
  import { generateHash } from '../utils/generate-hash.js';
12
12
  import { ItemsService } from './items.js';
13
13
  /**
@@ -164,7 +164,7 @@ export class PayloadService {
164
164
  const aggregateKeys = Object.keys(payload[0]).filter((key) => key.includes('->'));
165
165
  if (aggregateKeys.length) {
166
166
  for (const item of payload) {
167
- Object.assign(item, flat.unflatten(pick(item, aggregateKeys), { delimiter: '->' }));
167
+ Object.assign(item, unflatten(pick(item, aggregateKeys), { delimiter: '->' }));
168
168
  aggregateKeys.forEach((key) => delete item[key]);
169
169
  }
170
170
  }
@@ -1,4 +1,5 @@
1
1
  import { toArray } from '@directus/utils';
2
+ import { version } from 'directus/version';
2
3
  import { merge } from 'lodash-es';
3
4
  import { Readable } from 'node:stream';
4
5
  import { performance } from 'perf_hooks';
@@ -11,7 +12,6 @@ import { rateLimiterGlobal } from '../middleware/rate-limiter-global.js';
11
12
  import { rateLimiter } from '../middleware/rate-limiter-ip.js';
12
13
  import { SERVER_ONLINE } from '../server.js';
13
14
  import { getStorage } from '../storage/index.js';
14
- import { version } from '../utils/package.js';
15
15
  import { toBoolean } from '../utils/to-boolean.js';
16
16
  import { SettingsService } from './settings.js';
17
17
  export class ServerService {
@@ -70,8 +70,6 @@ export class ServerService {
70
70
  default: env['QUERY_LIMIT_DEFAULT'],
71
71
  max: Number.isFinite(env['QUERY_LIMIT_MAX']) ? env['QUERY_LIMIT_MAX'] : -1,
72
72
  };
73
- }
74
- if (this.accountability?.user) {
75
73
  if (toBoolean(env['WEBSOCKETS_ENABLED'])) {
76
74
  info['websocket'] = {};
77
75
  info['websocket'].rest = toBoolean(env['WEBSOCKETS_REST_ENABLED'])
@@ -93,6 +91,7 @@ export class ServerService {
93
91
  else {
94
92
  info['websocket'] = false;
95
93
  }
94
+ info['version'] = version;
96
95
  }
97
96
  return info;
98
97
  }
@@ -1,13 +1,13 @@
1
1
  import formatTitle from '@directus/format-title';
2
2
  import { spec } from '@directus/specs';
3
+ import { version } from 'directus/version';
3
4
  import { cloneDeep, mergeWith } from 'lodash-es';
4
5
  import { OAS_REQUIRED_SCHEMAS } from '../constants.js';
5
6
  import getDatabase from '../database/index.js';
6
7
  import env from '../env.js';
7
8
  import { getRelationType } from '../utils/get-relation-type.js';
8
- import { version } from '../utils/package.js';
9
- import { GraphQLService } from './graphql/index.js';
10
9
  import { reduceSchema } from '../utils/reduce-schema.js';
10
+ import { GraphQLService } from './graphql/index.js';
11
11
  export class SpecificationService {
12
12
  accountability;
13
13
  knex;
@@ -19,7 +19,7 @@ export class SpecificationService {
19
19
  this.knex = knex || getDatabase();
20
20
  this.schema = schema;
21
21
  this.oas = new OASSpecsService({ knex, schema, accountability });
22
- this.graphql = new GraphQLSpecsService({ knex, schema });
22
+ this.graphql = new GraphQLSpecsService({ knex, schema, accountability });
23
23
  }
24
24
  }
25
25
  class OASSpecsService {
@@ -116,7 +116,7 @@ export class UsersService extends ItemsService {
116
116
  */
117
117
  async getUserByEmail(email) {
118
118
  return await this.knex
119
- .select('id', 'role', 'status', 'password')
119
+ .select('id', 'role', 'status', 'password', 'email')
120
120
  .from('directus_users')
121
121
  .whereRaw(`LOWER(??) = ?`, ['email', email.toLowerCase()])
122
122
  .first();
@@ -325,13 +325,13 @@ export class UsersService extends ItemsService {
325
325
  if (isEmpty(user) || user.status === 'invited') {
326
326
  const subjectLine = subject ?? "You've been invited";
327
327
  await mailService.send({
328
- to: email,
328
+ to: user.email,
329
329
  subject: subjectLine,
330
330
  template: {
331
331
  name: 'user-invitation',
332
332
  data: {
333
333
  url: this.inviteUrl(email, url),
334
- email,
334
+ email: user.email,
335
335
  },
336
336
  },
337
337
  });
@@ -369,20 +369,20 @@ export class UsersService extends ItemsService {
369
369
  knex: this.knex,
370
370
  accountability: this.accountability,
371
371
  });
372
- const payload = { email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
372
+ const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
373
373
  const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
374
374
  const acceptURL = url
375
375
  ? new Url(url).setQuery('token', token).toString()
376
376
  : new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
377
377
  const subjectLine = subject ? subject : 'Password Reset Request';
378
378
  await mailService.send({
379
- to: email,
379
+ to: user.email,
380
380
  subject: subjectLine,
381
381
  template: {
382
382
  name: 'password-reset',
383
383
  data: {
384
384
  url: acceptURL,
385
- email,
385
+ email: user.email,
386
386
  },
387
387
  },
388
388
  });
@@ -0,0 +1,4 @@
1
+ export * from './lib/get-report.js';
2
+ export * from './lib/init-telemetry.js';
3
+ export * from './lib/send-report.js';
4
+ export * from './lib/track.js';
@@ -0,0 +1,4 @@
1
+ export * from './lib/get-report.js';
2
+ export * from './lib/init-telemetry.js';
3
+ export * from './lib/send-report.js';
4
+ export * from './lib/track.js';
@@ -0,0 +1,5 @@
1
+ import type { TelemetryReport } from '../types/report.js';
2
+ /**
3
+ * Create a telemetry report about the anonymous usage of the current installation
4
+ */
5
+ export declare const getReport: () => Promise<TelemetryReport>;
@@ -0,0 +1,42 @@
1
+ import { version } from 'directus/version';
2
+ import { getDatabase, getDatabaseClient } from '../../database/index.js';
3
+ import { useEnv } from '../../env.js';
4
+ import { getItemCount } from '../utils/get-item-count.js';
5
+ import { getUserCount } from '../utils/get-user-count.js';
6
+ 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',
14
+ ];
15
+ /**
16
+ * Create a telemetry report about the anonymous usage of the current installation
17
+ */
18
+ export const getReport = async () => {
19
+ const db = getDatabase();
20
+ const env = useEnv();
21
+ const [basicCounts, userCounts, userItemCount] = await Promise.all([
22
+ getItemCount(db, basicCountCollections),
23
+ getUserCount(db),
24
+ getUserItemCount(db),
25
+ ]);
26
+ return {
27
+ url: env['PUBLIC_URL'],
28
+ version: version,
29
+ database: getDatabaseClient(),
30
+ dashboards: basicCounts.directus_dashboards,
31
+ extensions: basicCounts.directus_extensions,
32
+ files: basicCounts.directus_files,
33
+ flows: basicCounts.directus_flows,
34
+ roles: basicCounts.directus_roles,
35
+ shares: basicCounts.directus_shares,
36
+ admin_users: userCounts.admin,
37
+ app_users: userCounts.app,
38
+ api_users: userCounts.api,
39
+ collections: userItemCount.collections,
40
+ items: userItemCount.items,
41
+ };
42
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Exported to be able to test the anonymous callback function
3
+ */
4
+ export declare const jobCallback: () => void;
5
+ /**
6
+ * Initialize the telemetry tracking. Will generate a report on start, and set a schedule to report
7
+ * every 6 hours
8
+ *
9
+ * @returns Whether or not telemetry has been initialized
10
+ */
11
+ export declare const initTelemetry: () => Promise<boolean>;
@@ -0,0 +1,30 @@
1
+ import { getCache } from '../../cache.js';
2
+ import { useEnv } from '../../env.js';
3
+ import { scheduleSynchronizedJob } from '../../utils/schedule.js';
4
+ import { toBoolean } from '../../utils/to-boolean.js';
5
+ import { track } from './track.js';
6
+ /**
7
+ * Exported to be able to test the anonymous callback function
8
+ */
9
+ export const jobCallback = () => {
10
+ track();
11
+ };
12
+ /**
13
+ * Initialize the telemetry tracking. Will generate a report on start, and set a schedule to report
14
+ * every 6 hours
15
+ *
16
+ * @returns Whether or not telemetry has been initialized
17
+ */
18
+ export const initTelemetry = async () => {
19
+ const env = useEnv();
20
+ if (toBoolean(env['TELEMETRY']) === false)
21
+ return false;
22
+ scheduleSynchronizedJob('telemetry', '0 */6 * * *', jobCallback);
23
+ const { lockCache } = getCache();
24
+ if (!(await lockCache.get('telemetry-lock'))) {
25
+ await lockCache.set('telemetry-lock', true, 30000);
26
+ track({ wait: false });
27
+ // Don't flush the lock. We want to debounce these calls across containers on startup
28
+ }
29
+ return true;
30
+ };
@@ -0,0 +1,5 @@
1
+ import type { TelemetryReport } from '../types/report.js';
2
+ /**
3
+ * Post an anonymous usage report to the centralized intake server
4
+ */
5
+ export declare const sendReport: (report: TelemetryReport) => Promise<void>;
@@ -0,0 +1,23 @@
1
+ import { URL } from 'node:url';
2
+ import { useEnv } from '../../env.js';
3
+ /**
4
+ * Post an anonymous usage report to the centralized intake server
5
+ */
6
+ export const sendReport = async (report) => {
7
+ const env = useEnv();
8
+ const url = new URL('/v1/metrics', env['TELEMETRY_URL']);
9
+ const headers = {
10
+ 'Content-Type': 'application/json',
11
+ };
12
+ if (env['TELEMETRY_AUTHORIZATION']) {
13
+ headers['Authorization'] = env['TELEMETRY_AUTHORIZATION'];
14
+ }
15
+ const res = await fetch(url, {
16
+ method: 'POST',
17
+ body: JSON.stringify(report),
18
+ headers,
19
+ });
20
+ if (!res.ok) {
21
+ throw new Error(`[${res.status}] ${await res.text()}`);
22
+ }
23
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Generate and send a report. Will log on error, but not throw. No need to be awaited
3
+ *
4
+ * @param opts Options for the tracking
5
+ * @param opts.wait Whether or not to wait a random amount of time between 0 and 30 minutes
6
+ * @returns whether or not the tracking was successful
7
+ */
8
+ export declare const track: (opts?: {
9
+ wait: boolean;
10
+ }) => Promise<boolean>;
@@ -0,0 +1,31 @@
1
+ import { setTimeout } from 'timers/promises';
2
+ import { useEnv } from '../../env.js';
3
+ import { useLogger } from '../../logger.js';
4
+ import { getRandomWaitTime } from '../utils/get-random-wait-time.js';
5
+ import { getReport } from './get-report.js';
6
+ import { sendReport } from './send-report.js';
7
+ /**
8
+ * Generate and send a report. Will log on error, but not throw. No need to be awaited
9
+ *
10
+ * @param opts Options for the tracking
11
+ * @param opts.wait Whether or not to wait a random amount of time between 0 and 30 minutes
12
+ * @returns whether or not the tracking was successful
13
+ */
14
+ export const track = async (opts = { wait: true }) => {
15
+ const env = useEnv();
16
+ const logger = useLogger();
17
+ if (opts.wait) {
18
+ await setTimeout(getRandomWaitTime());
19
+ }
20
+ try {
21
+ const report = await getReport();
22
+ await sendReport(report);
23
+ return true;
24
+ }
25
+ catch (err) {
26
+ if (env['NODE_ENV'] === 'development') {
27
+ logger.error(err);
28
+ }
29
+ return false;
30
+ }
31
+ };
@@ -0,0 +1,58 @@
1
+ export interface TelemetryReport {
2
+ /**
3
+ * The project's web-facing public URL
4
+ */
5
+ url: string;
6
+ /**
7
+ * Current Directus version in use
8
+ */
9
+ version: string;
10
+ /**
11
+ * Database client in use
12
+ */
13
+ database: string;
14
+ /**
15
+ * Number of users in the system that have admin access to the system
16
+ */
17
+ admin_users: number;
18
+ /**
19
+ * Number of users that can access the app, but don't have admin access
20
+ */
21
+ app_users: number;
22
+ /**
23
+ * Number of users that can only access the API
24
+ */
25
+ api_users: number;
26
+ /**
27
+ * Number of unique roles in the system
28
+ */
29
+ roles: number;
30
+ /**
31
+ * Number of unique flows in the system
32
+ */
33
+ flows: number;
34
+ /**
35
+ * Number of unique dashboards in the system
36
+ */
37
+ dashboards: number;
38
+ /**
39
+ * Number of installed extensions in the system. Does not differentiate between enabled/disabled
40
+ */
41
+ extensions: number;
42
+ /**
43
+ * Number of Directus-managed collections
44
+ */
45
+ collections: number;
46
+ /**
47
+ * Total number of items in the non-system tables
48
+ */
49
+ items: number;
50
+ /**
51
+ * Number of files in the system
52
+ */
53
+ files: number;
54
+ /**
55
+ * Number of shares in the system
56
+ */
57
+ shares: number;
58
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { type Knex } from 'knex';
2
+ export interface CollectionCount {
3
+ collection: string;
4
+ count: number;
5
+ }
6
+ /**
7
+ * Get the item count of the given collection in the given database
8
+ * @param db Knex instance to count against
9
+ * @param collection Table to count rows in
10
+ * @returns Collection name and count
11
+ */
12
+ export declare const countCollection: (db: Knex, collection: string) => Promise<CollectionCount>;
13
+ /**
14
+ * Merge the given collection count in the object accumulator
15
+ * Intended for use with .reduce()
16
+ * @param acc Accumulator
17
+ * @param value Current collection count object in array
18
+ * @returns Updated accumulator
19
+ */
20
+ export declare const mergeResults: (acc: Record<string, number>, value: CollectionCount) => Record<string, number>;
21
+ /**
22
+ * Get an object of item counts for the given collections
23
+ * @param db Database instance to get counts in
24
+ * @param collections Array of table names to get count from
25
+ */
26
+ export declare const getItemCount: <T extends readonly string[]>(db: Knex, collections: T) => Promise<Record<T[number], number>>;
@@ -0,0 +1,36 @@
1
+ import {} from 'knex';
2
+ import pLimit from 'p-limit';
3
+ /**
4
+ * Get the item count of the given collection in the given database
5
+ * @param db Knex instance to count against
6
+ * @param collection Table to count rows in
7
+ * @returns Collection name and count
8
+ */
9
+ export const countCollection = async (db, collection) => {
10
+ const count = await db.count('*', { as: 'count' }).from(collection).first();
11
+ return { collection, count: Number(count?.['count'] ?? 0) };
12
+ };
13
+ /**
14
+ * Merge the given collection count in the object accumulator
15
+ * Intended for use with .reduce()
16
+ * @param acc Accumulator
17
+ * @param value Current collection count object in array
18
+ * @returns Updated accumulator
19
+ */
20
+ export const mergeResults = (acc, value) => {
21
+ acc[value.collection] = value.count;
22
+ return acc;
23
+ };
24
+ /**
25
+ * Get an object of item counts for the given collections
26
+ * @param db Database instance to get counts in
27
+ * @param collections Array of table names to get count from
28
+ */
29
+ export const getItemCount = async (db, collections) => {
30
+ // Counts can be a little heavy if the table is very large, so we'll only ever execute 3 of these
31
+ // queries simultaneously to not overload the database
32
+ const limit = pLimit(3);
33
+ const calls = collections.map((collection) => limit(countCollection, db, collection));
34
+ const results = await Promise.all(calls);
35
+ return results.reduce(mergeResults, {});
36
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns randomized value between 0 and 1.8e+6 (30min in ms) intended to be used as the randomized wait for
3
+ * telemetry tracking
4
+ */
5
+ export declare const getRandomWaitTime: () => number;