@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.
- package/dist/app.js +3 -3
- package/dist/auth/drivers/oauth2.js +2 -3
- package/dist/auth/drivers/openid.js +2 -3
- package/dist/cli/commands/schema/apply.js +44 -33
- package/dist/cli/index.js +2 -2
- package/dist/database/index.d.ts +2 -1
- package/dist/database/index.js +2 -1
- package/dist/database/system-data/fields/settings.yaml +4 -0
- package/dist/database/system-data/fields/users.yaml +4 -0
- package/dist/database/system-data/relations/relations.yaml +4 -0
- package/dist/emitter.d.ts +1 -0
- package/dist/emitter.js +2 -1
- package/dist/env.d.ts +1 -0
- package/dist/env.js +6 -0
- package/dist/extensions/lib/get-shared-deps-mapping.js +1 -1
- package/dist/extensions/lib/sandbox/register/route.d.ts +1 -0
- package/dist/extensions/manager.d.ts +5 -0
- package/dist/extensions/manager.js +42 -16
- package/dist/logger.d.ts +2 -1
- package/dist/logger.js +1 -0
- package/dist/middleware/rate-limiter-ip.js +14 -11
- package/dist/redis/create-redis.d.ts +7 -0
- package/dist/redis/create-redis.js +12 -0
- package/dist/redis/index.d.ts +2 -0
- package/dist/redis/index.js +2 -0
- package/dist/redis/use-redis.d.ts +16 -0
- package/dist/redis/use-redis.js +22 -0
- package/dist/server.d.ts +2 -0
- package/dist/services/extensions.js +1 -1
- package/dist/services/graphql/index.js +50 -15
- package/dist/services/payload.js +3 -3
- package/dist/services/server.js +2 -3
- package/dist/services/specifications.js +3 -3
- package/dist/services/users.js +6 -6
- package/dist/telemetry/index.d.ts +4 -0
- package/dist/telemetry/index.js +4 -0
- package/dist/telemetry/lib/get-report.d.ts +5 -0
- package/dist/telemetry/lib/get-report.js +42 -0
- package/dist/telemetry/lib/init-telemetry.d.ts +11 -0
- package/dist/telemetry/lib/init-telemetry.js +30 -0
- package/dist/telemetry/lib/send-report.d.ts +5 -0
- package/dist/telemetry/lib/send-report.js +23 -0
- package/dist/telemetry/lib/track.d.ts +10 -0
- package/dist/telemetry/lib/track.js +31 -0
- package/dist/telemetry/types/report.d.ts +58 -0
- package/dist/telemetry/types/report.js +1 -0
- package/dist/telemetry/utils/get-item-count.d.ts +26 -0
- package/dist/telemetry/utils/get-item-count.js +36 -0
- package/dist/telemetry/utils/get-random-wait-time.d.ts +5 -0
- package/dist/telemetry/utils/get-random-wait-time.js +5 -0
- package/dist/telemetry/utils/get-user-count.d.ts +7 -0
- package/dist/telemetry/utils/get-user-count.js +30 -0
- package/dist/telemetry/utils/get-user-item-count.d.ts +13 -0
- package/dist/telemetry/utils/get-user-item-count.js +18 -0
- package/dist/utils/apply-query.js +13 -2
- package/dist/utils/get-cache-key.js +1 -1
- package/dist/utils/get-ip-from-req.d.ts +1 -1
- package/dist/utils/get-ip-from-req.js +1 -1
- package/dist/utils/get-permissions.js +0 -3
- package/dist/utils/get-snapshot-diff.js +2 -1
- package/dist/utils/get-snapshot.js +1 -1
- package/dist/utils/get-versioned-hash.js +1 -1
- package/dist/utils/md.d.ts +1 -1
- package/dist/utils/md.js +3 -2
- package/dist/utils/merge-permissions.js +19 -11
- package/dist/utils/validate-query.js +1 -0
- package/dist/utils/validate-snapshot.js +3 -3
- package/dist/websocket/controllers/base.d.ts +2 -0
- package/dist/websocket/controllers/graphql.d.ts +2 -0
- package/dist/websocket/controllers/graphql.js +1 -1
- package/dist/websocket/controllers/index.d.ts +2 -0
- package/dist/websocket/controllers/rest.d.ts +2 -0
- package/dist/websocket/types.d.ts +3 -1
- package/package.json +108 -109
- package/dist/utils/package.d.ts +0 -2
- package/dist/utils/package.js +0 -6
- package/dist/utils/telemetry.d.ts +0 -1
- 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
|
-
|
|
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
|
-
|
|
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 = {
|
package/dist/services/payload.js
CHANGED
|
@@ -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
|
|
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,
|
|
167
|
+
Object.assign(item, unflatten(pick(item, aggregateKeys), { delimiter: '->' }));
|
|
168
168
|
aggregateKeys.forEach((key) => delete item[key]);
|
|
169
169
|
}
|
|
170
170
|
}
|
package/dist/services/server.js
CHANGED
|
@@ -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 {
|
package/dist/services/users.js
CHANGED
|
@@ -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,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,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
|
+
};
|