@wbce-d9/api 12.7.2 → 12.7.3

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.
@@ -133,7 +133,11 @@ asyncHandler(async (req, res) => {
133
133
  }
134
134
  }
135
135
  }
136
- const { stream, file, stat } = await service.getAsset(id, transformation, range);
136
+ const isHead = req.method.toLowerCase() === 'head';
137
+ const asset = isHead
138
+ ? await service.getAssetInfo(id, transformation, range)
139
+ : await service.getAsset(id, transformation, range);
140
+ const { stream, file, stat } = asset;
137
141
  const filename = req.params['filename'] ?? file.filename_download;
138
142
  res.attachment(filename);
139
143
  res.setHeader('Content-Type', file.type);
@@ -170,10 +174,7 @@ asyncHandler(async (req, res) => {
170
174
  if ('download' in req.query === false) {
171
175
  res.setHeader('Content-Disposition', contentDisposition(filename, { type: 'inline' }));
172
176
  }
173
- if (req.method.toLowerCase() === 'head') {
174
- res.status(200);
175
- res.setHeader('Accept-Ranges', 'bytes');
176
- res.setHeader('Content-Length', stat.size);
177
+ if (!stream) {
177
178
  return res.end();
178
179
  }
179
180
  let isDataSent = false;
package/dist/flows.d.ts CHANGED
@@ -23,8 +23,9 @@ declare class FlowManager {
23
23
  private unload;
24
24
  private executeFlow;
25
25
  /**
26
- * Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
27
- * The data is returned as is when there is nothing to conceal.
26
+ * Return a copy of the trigger data with its secrets masked: the concealed fields of an event payload, as they
27
+ * would be on read, and the credentials (authorization header, cookies, access_token query parameter) of a
28
+ * webhook or manual request. The data is returned as is when there is nothing to conceal.
28
29
  */
29
30
  private concealEventPayload;
30
31
  /**
package/dist/flows.js CHANGED
@@ -27,6 +27,11 @@ const redactLogs = fastRedact({
27
27
  paths: ['*.headers.authorization', '*.access_token', '*.headers.cookie'],
28
28
  serialize: false,
29
29
  });
30
+ const redactRequestCredentials = fastRedact({
31
+ censor: '--redacted--',
32
+ paths: ['headers.authorization', 'headers.cookie', 'query.access_token'],
33
+ serialize: false,
34
+ });
30
35
  export function getFlowManager() {
31
36
  if (flowManager) {
32
37
  return flowManager;
@@ -337,10 +342,13 @@ class FlowManager {
337
342
  return undefined;
338
343
  }
339
344
  /**
340
- * Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
341
- * The data is returned as is when there is nothing to conceal.
345
+ * Return a copy of the trigger data with its secrets masked: the concealed fields of an event payload, as they
346
+ * would be on read, and the credentials (authorization header, cookies, access_token query parameter) of a
347
+ * webhook or manual request. The data is returned as is when there is nothing to conceal.
342
348
  */
343
349
  async concealEventPayload(flow, data, knex, schema) {
350
+ if (flow.trigger === 'webhook' || flow.trigger === 'manual')
351
+ return redactRequestCredentials(cloneDeep(data));
344
352
  if (flow.trigger !== 'event')
345
353
  return data;
346
354
  const { collection, payload } = data;
@@ -2,6 +2,8 @@ import { parseJSON } from '@wbce-d9/utils';
2
2
  import { getOperationAST, parse, Source } from 'graphql';
3
3
  import { InvalidPayloadException, InvalidQueryException, MethodNotAllowedException } from '../exceptions/index.js';
4
4
  import asyncHandler from '../utils/async-handler.js';
5
+ import { getGraphQLQueryCost } from '../utils/get-graphql-query-cost.js';
6
+ import { consumeRateLimit } from './rate-limiter-ip.js';
5
7
  export const parseGraphQL = asyncHandler(async (req, res, next) => {
6
8
  if (req.method !== 'GET' && req.method !== 'POST') {
7
9
  throw new MethodNotAllowedException('GraphQL only supports GET and POST requests.', { allow: ['GET', 'POST'] });
@@ -52,6 +54,12 @@ export const parseGraphQL = asyncHandler(async (req, res, next) => {
52
54
  if (operationAST?.operation === 'mutation') {
53
55
  res.locals['cache'] = false;
54
56
  }
57
+ // A GraphQL request costs one rate limit point per root field. The request itself already consumed one point,
58
+ // so only the remaining cost is consumed here. Does nothing when the rate limiter is disabled.
59
+ const cost = getGraphQLQueryCost(document, operationAST);
60
+ if (cost > 1) {
61
+ await consumeRateLimit(req, res, cost - 1);
62
+ }
55
63
  res.locals['graphqlParams'] = {
56
64
  document,
57
65
  query,
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,13 @@
1
- import type { RequestHandler } from 'express';
2
- import type { RateLimiterMemcache, RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
1
+ import type { Request, RequestHandler, Response } from 'express';
2
+ import type { RateLimiterAbstract, RateLimiterMemcache, RateLimiterMemory, RateLimiterRedis } from 'rate-limiter-flexible';
3
3
  declare let checkRateLimit: RequestHandler;
4
4
  export declare let rateLimiter: RateLimiterRedis | RateLimiterMemcache | RateLimiterMemory;
5
+ /**
6
+ * Consume rate limit points for the client IP of the request. A request can consume more than one
7
+ * point when its cost is higher than a regular request, so expensive operations (like a GraphQL
8
+ * document selecting many root fields) are accounted for in the same budget.
9
+ * Does nothing when the rate limiter is disabled.
10
+ */
11
+ export declare function createRateLimitConsumer(limiter: RateLimiterAbstract | undefined): (req: Request, res: Response, points?: number) => Promise<void>;
12
+ export declare const consumeRateLimit: (req: Request, res: Response, points?: number) => Promise<void>;
5
13
  export default checkRateLimit;
@@ -11,8 +11,22 @@ if (env['RATE_LIMITER_ENABLED'] === true) {
11
11
  validateEnv(['RATE_LIMITER_STORE', 'RATE_LIMITER_DURATION', 'RATE_LIMITER_POINTS']);
12
12
  rateLimiter = createRateLimiter('RATE_LIMITER');
13
13
  checkRateLimit = asyncHandler(async (req, res, next) => {
14
+ await consumeRateLimit(req, res, 1);
15
+ next();
16
+ });
17
+ }
18
+ /**
19
+ * Consume rate limit points for the client IP of the request. A request can consume more than one
20
+ * point when its cost is higher than a regular request, so expensive operations (like a GraphQL
21
+ * document selecting many root fields) are accounted for in the same budget.
22
+ * Does nothing when the rate limiter is disabled.
23
+ */
24
+ export function createRateLimitConsumer(limiter) {
25
+ return async function consumeRateLimit(req, res, points = 1) {
26
+ if (!limiter)
27
+ return;
14
28
  try {
15
- await rateLimiter.consume(getIPFromReq(req), 1);
29
+ await limiter.consume(getIPFromReq(req), points);
16
30
  }
17
31
  catch (rateLimiterRes) {
18
32
  if (rateLimiterRes instanceof Error)
@@ -23,7 +37,7 @@ if (env['RATE_LIMITER_ENABLED'] === true) {
23
37
  reset: new Date(Date.now() + rateLimiterRes.msBeforeNext),
24
38
  });
25
39
  }
26
- next();
27
- });
40
+ };
28
41
  }
42
+ export const consumeRateLimit = createRateLimitConsumer(rateLimiter);
29
43
  export default checkRateLimit;
@@ -0,0 +1 @@
1
+ export {};
@@ -15,4 +15,10 @@ export declare class AssetsService {
15
15
  file: any;
16
16
  stat: Stat;
17
17
  }>;
18
+ getAssetInfo(id: string, transformation: TransformationParams, range?: Range): Promise<{
19
+ file: any;
20
+ stat: Stat;
21
+ stream?: never;
22
+ }>;
23
+ private resolveAsset;
18
24
  }
@@ -27,6 +27,19 @@ export class AssetsService {
27
27
  }
28
28
  async getAsset(id, transformation, range) {
29
29
  const storage = await getStorage();
30
+ const { file, filename, stat } = await this.resolveAsset(storage, id, transformation, range);
31
+ return {
32
+ stream: await storage.location(file.storage).read(filename, range),
33
+ file,
34
+ stat,
35
+ };
36
+ }
37
+ async getAssetInfo(id, transformation, range) {
38
+ const storage = await getStorage();
39
+ const { file, stat } = await this.resolveAsset(storage, id, transformation, range);
40
+ return { file, stat };
41
+ }
42
+ async resolveAsset(storage, id, transformation, range) {
30
43
  const publicSettings = await this.knex
31
44
  .select('project_logo', 'public_background', 'public_foreground')
32
45
  .from('directus_settings')
@@ -93,7 +106,7 @@ export class AssetsService {
93
106
  }
94
107
  if (exists) {
95
108
  return {
96
- stream: await storage.location(file.storage).read(assetFilename, range),
109
+ filename: assetFilename,
97
110
  file,
98
111
  stat: await storage.location(file.storage).stat(assetFilename),
99
112
  };
@@ -114,7 +127,6 @@ export class AssetsService {
114
127
  service: 'files',
115
128
  });
116
129
  }
117
- const readStream = await storage.location(file.storage).read(file.filename_disk, range);
118
130
  const transformer = sharp({
119
131
  limitInputPixels: Math.pow(env['ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION'], 2),
120
132
  sequentialRead: true,
@@ -126,21 +138,28 @@ export class AssetsService {
126
138
  if (transforms.find((transform) => transform[0] === 'rotate') === undefined)
127
139
  transformer.rotate();
128
140
  transforms.forEach(([method, ...args]) => transformer[method].apply(transformer, args));
141
+ // Sharp validates synchronously: only open the stream once the request is known to be valid
142
+ const readStream = await storage.location(file.storage).read(file.filename_disk, range);
129
143
  readStream.on('error', (e) => {
130
144
  logger.error(e, `Couldn't transform file ${file.id}`);
131
145
  readStream.unpipe(transformer);
132
146
  });
133
- await storage.location(file.storage).write(assetFilename, readStream.pipe(transformer), type);
147
+ try {
148
+ await storage.location(file.storage).write(assetFilename, readStream.pipe(transformer), type);
149
+ }
150
+ catch (error) {
151
+ readStream.destroy();
152
+ throw error;
153
+ }
134
154
  return {
135
- stream: await storage.location(file.storage).read(assetFilename, range),
155
+ filename: assetFilename,
136
156
  stat: await storage.location(file.storage).stat(assetFilename),
137
157
  file,
138
158
  };
139
159
  }
140
160
  else {
141
- const readStream = await storage.location(file.storage).read(file.filename_disk, range);
142
161
  const stat = await storage.location(file.storage).stat(file.filename_disk);
143
- return { stream: readStream, file, stat };
162
+ return { filename: file.filename_disk, file, stat };
144
163
  }
145
164
  }
146
165
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -45,6 +45,7 @@ import { GraphQLHash } from './types/hash.js';
45
45
  import { GraphQLStringOrFloat } from './types/string-or-float.js';
46
46
  import { GraphQLVoid } from './types/void.js';
47
47
  import { addPathToValidationError } from './utils/add-path-to-validation-error.js';
48
+ import { dedupeQueryResolvers } from './utils/dedupe-query-resolvers.js';
48
49
  import processError from './utils/process-error.js';
49
50
  const validationRules = Array.from(specifiedRules);
50
51
  if (env['GRAPHQL_INTROSPECTION'] === false) {
@@ -212,7 +213,9 @@ export class GraphQLService {
212
213
  if (type === 'sdl') {
213
214
  return schemaComposer.toSDL();
214
215
  }
215
- return schemaComposer.buildSchema();
216
+ const builtSchema = schemaComposer.buildSchema();
217
+ dedupeQueryResolvers(builtSchema);
218
+ return builtSchema;
216
219
  /**
217
220
  * Construct an object of types for every collection, using the permitted fields per action type
218
221
  * as it's fields.
@@ -0,0 +1,9 @@
1
+ import type { GraphQLSchema } from 'graphql';
2
+ /**
3
+ * Make root query fields that are repeated under several aliases with the same arguments resolve
4
+ * only once per execution, so a single request can't multiply expensive resolvers (eg. server_health).
5
+ * Only fields returning a leaf type are concerned: as they have no selection, their result only
6
+ * depends on their arguments.
7
+ * Results are cached on the schema, which must therefore be built for a single execution.
8
+ */
9
+ export declare function dedupeQueryResolvers(schema: GraphQLSchema): void;
@@ -0,0 +1,24 @@
1
+ import { getNamedType, isLeafType } from 'graphql';
2
+ import hash from 'object-hash';
3
+ /**
4
+ * Make root query fields that are repeated under several aliases with the same arguments resolve
5
+ * only once per execution, so a single request can't multiply expensive resolvers (eg. server_health).
6
+ * Only fields returning a leaf type are concerned: as they have no selection, their result only
7
+ * depends on their arguments.
8
+ * Results are cached on the schema, which must therefore be built for a single execution.
9
+ */
10
+ export function dedupeQueryResolvers(schema) {
11
+ const fields = schema.getQueryType()?.getFields() ?? {};
12
+ const results = new Map();
13
+ for (const field of Object.values(fields)) {
14
+ const resolve = field.resolve;
15
+ if (!resolve || !isLeafType(getNamedType(field.type)))
16
+ continue;
17
+ field.resolve = (source, args, context, info) => {
18
+ const key = hash({ field: info.fieldName, args });
19
+ if (!results.has(key))
20
+ results.set(key, resolve(source, args, context, info));
21
+ return results.get(key);
22
+ };
23
+ }
24
+ }
@@ -0,0 +1,12 @@
1
+ import type { DocumentNode, OperationDefinitionNode } from 'graphql';
2
+ /**
3
+ * Cost of a GraphQL document in rate limit points: one point per root field of the executed
4
+ * operation. Every root field is resolved on its own, so it is one unit of work (a collection read,
5
+ * a mutation, ...) whatever its arguments: repeating fields under aliases with different arguments
6
+ * consumes points instead of being free. Nested fields are not counted, as they are resolved from
7
+ * the root field.
8
+ * The operation is resolved by the caller (getOperationAST); when there is none (invalid document or
9
+ * missing operation name), the cost is 0: validation rejects those, and the request still costs its
10
+ * base point.
11
+ */
12
+ export declare function getGraphQLQueryCost(document: DocumentNode, operation: OperationDefinitionNode | null | undefined): number;
@@ -0,0 +1,45 @@
1
+ import { Kind } from 'graphql';
2
+ /**
3
+ * Cost of a GraphQL document in rate limit points: one point per root field of the executed
4
+ * operation. Every root field is resolved on its own, so it is one unit of work (a collection read,
5
+ * a mutation, ...) whatever its arguments: repeating fields under aliases with different arguments
6
+ * consumes points instead of being free. Nested fields are not counted, as they are resolved from
7
+ * the root field.
8
+ * The operation is resolved by the caller (getOperationAST); when there is none (invalid document or
9
+ * missing operation name), the cost is 0: validation rejects those, and the request still costs its
10
+ * base point.
11
+ */
12
+ export function getGraphQLQueryCost(document, operation) {
13
+ if (!operation)
14
+ return 0;
15
+ const fragments = new Map();
16
+ for (const definition of document.definitions) {
17
+ if (definition.kind === Kind.FRAGMENT_DEFINITION)
18
+ fragments.set(definition.name.value, definition);
19
+ }
20
+ return countRootFields(operation.selectionSet, fragments, new Set());
21
+ }
22
+ function countRootFields(selectionSet, fragments, visited) {
23
+ let fields = 0;
24
+ for (const selection of selectionSet.selections) {
25
+ if (selection.kind === Kind.FIELD) {
26
+ // A pointer resolved by the GraphQL engine, not by one of our resolvers
27
+ if (selection.name.value === '__typename')
28
+ continue;
29
+ fields++;
30
+ }
31
+ else if (selection.kind === Kind.INLINE_FRAGMENT) {
32
+ fields += countRootFields(selection.selectionSet, fragments, visited);
33
+ }
34
+ else if (selection.kind === Kind.FRAGMENT_SPREAD) {
35
+ if (visited.has(selection.name.value))
36
+ continue;
37
+ const fragment = fragments.get(selection.name.value);
38
+ if (fragment) {
39
+ visited.add(selection.name.value);
40
+ fields += countRootFields(fragment.selectionSet, fragments, visited);
41
+ }
42
+ }
43
+ }
44
+ return fields;
45
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.7.2",
3
+ "version": "12.7.3",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -126,7 +126,7 @@
126
126
  "ms": "2.1.3",
127
127
  "node-cron": "3.0.2",
128
128
  "node-machine-id": "1.1.12",
129
- "nodemailer": "10.0.12",
129
+ "nodemailer": "10.2.1",
130
130
  "object-hash": "3.0.0",
131
131
  "openid-client": "6.8.2",
132
132
  "otplib": "12.0.1",
@@ -136,7 +136,7 @@
136
136
  "qs": "6.16.0",
137
137
  "rate-limiter-flexible": "2.4.1",
138
138
  "samlify": "2.13.1",
139
- "sanitize-html": "2.17.7",
139
+ "sanitize-html": "2.18.1",
140
140
  "sharp": "0.35.5",
141
141
  "snappy": "7.2.2",
142
142
  "stream-json": "3.7.0",
@@ -144,20 +144,20 @@
144
144
  "tmp-promise": "3.0.3",
145
145
  "tsx": "3.12.6",
146
146
  "uuid-validate": "0.0.3",
147
+ "@wbce-d9/app": "11.3.1",
147
148
  "@wbce-d9/constants": "10.0.0",
148
149
  "@wbce-d9/exceptions": "10.0.1",
149
- "@wbce-d9/app": "11.3.0",
150
150
  "@wbce-d9/extensions-sdk": "10.1.0",
151
+ "@wbce-d9/schema": "10.0.0",
152
+ "@wbce-d9/specs": "9.26.5",
153
+ "@wbce-d9/storage-driver-cloudinary": "10.0.7",
151
154
  "@wbce-d9/storage": "10.0.0",
152
155
  "@wbce-d9/storage-driver-azure": "10.0.5",
153
- "@wbce-d9/specs": "9.26.5",
154
156
  "@wbce-d9/storage-driver-gcs": "10.0.5",
155
- "@wbce-d9/schema": "10.0.0",
156
- "@wbce-d9/storage-driver-local": "10.0.5",
157
- "@wbce-d9/storage-driver-s3": "10.0.5",
158
- "@wbce-d9/storage-driver-cloudinary": "10.0.6",
159
- "@wbce-d9/update-check": "10.0.4",
160
- "@wbce-d9/utils": "10.0.5"
157
+ "@wbce-d9/storage-driver-s3": "10.0.6",
158
+ "@wbce-d9/utils": "10.0.5",
159
+ "@wbce-d9/update-check": "10.0.5",
160
+ "@wbce-d9/storage-driver-local": "10.0.5"
161
161
  },
162
162
  "devDependencies": {
163
163
  "@ngneat/falso": "6.4.0",
@@ -171,11 +171,11 @@
171
171
  "@types/supertest": "2.0.12",
172
172
  "@types/uuid": "9.0.1",
173
173
  "@types/wellknown": "0.5.4",
174
- "@vitest/coverage-v8": "4.1.11",
174
+ "@vitest/coverage-v8": "4.1.12",
175
175
  "@wbce-d9/tsconfig": "2.0.0",
176
176
  "supertest": "6.3.3",
177
177
  "typescript": "5.0.4",
178
- "vitest": "4.1.11",
178
+ "vitest": "4.1.12",
179
179
  "@types/async": "3.2.19",
180
180
  "@types/busboy": "1.5.0",
181
181
  "@types/bytes": "3.1.1",