@wbce-d9/api 12.7.1 → 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.
- package/dist/controllers/assets.js +6 -5
- package/dist/flows.d.ts +3 -2
- package/dist/flows.js +10 -2
- package/dist/middleware/graphql.js +8 -0
- package/dist/middleware/graphql.test.d.ts +1 -0
- package/dist/middleware/rate-limiter-ip.d.ts +10 -2
- package/dist/middleware/rate-limiter-ip.js +17 -3
- package/dist/middleware/rate-limiter-ip.test.d.ts +1 -0
- package/dist/services/assets.d.ts +6 -0
- package/dist/services/assets.js +25 -6
- package/dist/services/assets.test.d.ts +1 -0
- package/dist/services/authorization.js +36 -0
- package/dist/services/graphql/index.js +4 -1
- package/dist/services/graphql/utils/dedupe-query-resolvers.d.ts +9 -0
- package/dist/services/graphql/utils/dedupe-query-resolvers.js +24 -0
- package/dist/services/graphql/utils/dedupe-query-resolvers.test.d.ts +1 -0
- package/dist/services/meta.d.ts +4 -0
- package/dist/services/meta.js +28 -27
- package/dist/types/ast.d.ts +15 -4
- package/dist/utils/apply-query.d.ts +4 -3
- package/dist/utils/apply-query.js +14 -5
- package/dist/utils/get-ast-from-query.js +20 -9
- package/dist/utils/get-graphql-query-cost.d.ts +12 -0
- package/dist/utils/get-graphql-query-cost.js +45 -0
- package/dist/utils/get-graphql-query-cost.test.d.ts +1 -0
- package/package.json +13 -13
|
@@ -133,7 +133,11 @@ asyncHandler(async (req, res) => {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
-
const
|
|
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 (
|
|
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
|
|
27
|
-
*
|
|
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
|
|
341
|
-
*
|
|
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
|
|
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
|
-
|
|
27
|
-
});
|
|
40
|
+
};
|
|
28
41
|
}
|
|
42
|
+
export const consumeRateLimit = createRateLimitConsumer(rateLimiter);
|
|
29
43
|
export default checkRateLimit;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/services/assets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
162
|
+
return { filename: file.filename_disk, file, stat };
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -36,6 +36,7 @@ export class AuthorizationService {
|
|
|
36
36
|
validateFields(ast);
|
|
37
37
|
validateFilterPermissions(ast, this.schema, action, this.accountability);
|
|
38
38
|
applyFilters(ast, this.accountability);
|
|
39
|
+
applySearch(ast, this.schema, this.accountability);
|
|
39
40
|
return ast;
|
|
40
41
|
/**
|
|
41
42
|
* Traverses the AST and returns an array of all collections that are being fetched
|
|
@@ -332,6 +333,41 @@ export class AuthorizationService {
|
|
|
332
333
|
delete query.filter;
|
|
333
334
|
}
|
|
334
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Sets the fields matched by the search query to the ones the role is allowed to read, so that the search can't
|
|
338
|
+
* be used to enumerate the values of the other fields
|
|
339
|
+
*/
|
|
340
|
+
function applySearch(ast, schema, accountability) {
|
|
341
|
+
if (ast.type === 'field' || ast.type === 'functionField')
|
|
342
|
+
return;
|
|
343
|
+
if (ast.type === 'a2o') {
|
|
344
|
+
for (const [collection, children] of Object.entries(ast.children)) {
|
|
345
|
+
setSearchFields(collection, ast.query[collection]);
|
|
346
|
+
for (const child of children)
|
|
347
|
+
applySearch(child, schema, accountability);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
setSearchFields(ast.name, ast.query);
|
|
352
|
+
for (const child of ast.children)
|
|
353
|
+
applySearch(child, schema, accountability);
|
|
354
|
+
}
|
|
355
|
+
function setSearchFields(collection, query) {
|
|
356
|
+
if (!query.search)
|
|
357
|
+
return;
|
|
358
|
+
// Searching reads the values, so the read permission applies whatever the action the AST is processed for
|
|
359
|
+
const permission = accountability?.permissions?.find((permission) => permission.collection === collection && permission.action === 'read');
|
|
360
|
+
let readableFields = [];
|
|
361
|
+
if (permission?.fields?.includes('*')) {
|
|
362
|
+
readableFields = Object.keys(schema.collections[collection].fields);
|
|
363
|
+
}
|
|
364
|
+
else if (permission?.fields) {
|
|
365
|
+
// Legacy permissions with an empty fields array only give access to the primary key
|
|
366
|
+
readableFields = permission.fields.length > 0 ? permission.fields : [schema.collections[collection].primary];
|
|
367
|
+
}
|
|
368
|
+
query.search.fields = readableFields;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
335
371
|
}
|
|
336
372
|
/**
|
|
337
373
|
* Checks if the provided payload matches the configured permissions, and adds the presets to the payload.
|
|
@@ -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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
package/dist/services/meta.d.ts
CHANGED
|
@@ -9,4 +9,8 @@ export declare class MetaService {
|
|
|
9
9
|
getMetaForQuery(collection: string, query: any): Promise<Record<string, any> | undefined>;
|
|
10
10
|
totalCount(collection: string): Promise<number>;
|
|
11
11
|
filterCount(collection: string, query: Query): Promise<number>;
|
|
12
|
+
/**
|
|
13
|
+
* Go through the same authorization as the read of the items, so that the counts match the returned data
|
|
14
|
+
*/
|
|
15
|
+
private getAuthorizedQuery;
|
|
12
16
|
}
|
package/dist/services/meta.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import getDatabase from '../database/index.js';
|
|
2
|
-
import { ForbiddenException } from '../exceptions/index.js';
|
|
3
2
|
import { applyFilter, applySearch } from '../utils/apply-query.js';
|
|
3
|
+
import getASTFromQuery from '../utils/get-ast-from-query.js';
|
|
4
|
+
import { AuthorizationService } from './authorization.js';
|
|
4
5
|
export class MetaService {
|
|
5
6
|
knex;
|
|
6
7
|
accountability;
|
|
@@ -30,42 +31,42 @@ export class MetaService {
|
|
|
30
31
|
}
|
|
31
32
|
async totalCount(collection) {
|
|
32
33
|
const dbQuery = this.knex(collection).count('*', { as: 'count' }).first();
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
});
|
|
37
|
-
if (!permissionsRecord)
|
|
38
|
-
throw new ForbiddenException();
|
|
39
|
-
const permissions = permissionsRecord.permissions ?? {};
|
|
40
|
-
applyFilter(this.knex, this.schema, dbQuery, permissions, collection, {});
|
|
34
|
+
// Only the permissions apply to the total count, not the filter and search of the query
|
|
35
|
+
const { filter } = await this.getAuthorizedQuery(collection, { fields: [] });
|
|
36
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
37
|
+
applyFilter(this.knex, this.schema, dbQuery, filter, collection, {});
|
|
41
38
|
}
|
|
42
39
|
const result = await dbQuery;
|
|
43
40
|
return Number(result?.count ?? 0);
|
|
44
41
|
}
|
|
45
42
|
async filterCount(collection, query) {
|
|
46
43
|
const dbQuery = this.knex(collection).count('*', { as: 'count' });
|
|
47
|
-
|
|
48
|
-
if (
|
|
49
|
-
const permissionsRecord = this.accountability?.permissions?.find((permission) => {
|
|
50
|
-
return permission.action === 'read' && permission.collection === collection;
|
|
51
|
-
});
|
|
52
|
-
if (!permissionsRecord)
|
|
53
|
-
throw new ForbiddenException();
|
|
54
|
-
const permissions = permissionsRecord.permissions ?? {};
|
|
55
|
-
if (Object.keys(filter).length > 0) {
|
|
56
|
-
filter = { _and: [permissions, filter] };
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
filter = permissions;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
if (Object.keys(filter).length > 0) {
|
|
44
|
+
const { filter, search } = await this.getAuthorizedQuery(collection, query);
|
|
45
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
63
46
|
applyFilter(this.knex, this.schema, dbQuery, filter, collection, {});
|
|
64
47
|
}
|
|
65
|
-
if (
|
|
66
|
-
applySearch(this.schema, dbQuery,
|
|
48
|
+
if (search) {
|
|
49
|
+
applySearch(this.schema, dbQuery, search, collection);
|
|
67
50
|
}
|
|
68
51
|
const records = await dbQuery;
|
|
69
52
|
return Number(records[0].count);
|
|
70
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Go through the same authorization as the read of the items, so that the counts match the returned data
|
|
56
|
+
*/
|
|
57
|
+
async getAuthorizedQuery(collection, query) {
|
|
58
|
+
let ast = await getASTFromQuery(collection, query, this.schema, {
|
|
59
|
+
accountability: this.accountability,
|
|
60
|
+
knex: this.knex,
|
|
61
|
+
});
|
|
62
|
+
if (this.accountability?.admin !== true) {
|
|
63
|
+
const authorizationService = new AuthorizationService({
|
|
64
|
+
accountability: this.accountability,
|
|
65
|
+
knex: this.knex,
|
|
66
|
+
schema: this.schema,
|
|
67
|
+
});
|
|
68
|
+
ast = await authorizationService.processAST(ast);
|
|
69
|
+
}
|
|
70
|
+
return ast.query;
|
|
71
|
+
}
|
|
71
72
|
}
|
package/dist/types/ast.d.ts
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import type { Query, Relation } from '@wbce-d9/types';
|
|
2
|
+
/**
|
|
3
|
+
* Search query, along with the fields it is allowed to match. For non-admin users, the fields are empty until the
|
|
4
|
+
* AuthorizationService sets the readable ones
|
|
5
|
+
*/
|
|
6
|
+
export type ASTSearch = {
|
|
7
|
+
fields: string[];
|
|
8
|
+
value: string;
|
|
9
|
+
};
|
|
10
|
+
export type ASTQuery = Omit<Query, 'search'> & {
|
|
11
|
+
search?: ASTSearch | null;
|
|
12
|
+
};
|
|
2
13
|
export type M2ONode = {
|
|
3
14
|
type: 'm2o';
|
|
4
15
|
name: string;
|
|
5
16
|
children: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
|
|
6
|
-
query:
|
|
17
|
+
query: ASTQuery;
|
|
7
18
|
fieldKey: string;
|
|
8
19
|
relation: Relation;
|
|
9
20
|
parentKey: string;
|
|
@@ -16,7 +27,7 @@ export type A2MNode = {
|
|
|
16
27
|
[collection: string]: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
|
|
17
28
|
};
|
|
18
29
|
query: {
|
|
19
|
-
[collection: string]:
|
|
30
|
+
[collection: string]: ASTQuery;
|
|
20
31
|
};
|
|
21
32
|
relatedKey: {
|
|
22
33
|
[collection: string]: string;
|
|
@@ -29,7 +40,7 @@ export type O2MNode = {
|
|
|
29
40
|
type: 'o2m';
|
|
30
41
|
name: string;
|
|
31
42
|
children: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
|
|
32
|
-
query:
|
|
43
|
+
query: ASTQuery;
|
|
33
44
|
fieldKey: string;
|
|
34
45
|
relation: Relation;
|
|
35
46
|
parentKey: string;
|
|
@@ -52,5 +63,5 @@ export type AST = {
|
|
|
52
63
|
type: 'root';
|
|
53
64
|
name: string;
|
|
54
65
|
children: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
|
|
55
|
-
query:
|
|
66
|
+
query: ASTQuery;
|
|
56
67
|
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type { Aggregate, Filter,
|
|
1
|
+
import type { Aggregate, Filter, SchemaOverview } from '@wbce-d9/types';
|
|
2
2
|
import type { Knex } from 'knex';
|
|
3
|
+
import type { ASTQuery, ASTSearch } from '../types/index.js';
|
|
3
4
|
import type { AliasMap } from './get-column-path.js';
|
|
4
5
|
export declare const generateAlias: (size?: number | undefined) => string;
|
|
5
6
|
/**
|
|
6
7
|
* Apply the Query to a given Knex query builder instance
|
|
7
8
|
*/
|
|
8
|
-
export default function applyQuery(knex: Knex, collection: string, dbQuery: Knex.QueryBuilder, query:
|
|
9
|
+
export default function applyQuery(knex: Knex, collection: string, dbQuery: Knex.QueryBuilder, query: ASTQuery, schema: SchemaOverview, options?: {
|
|
9
10
|
aliasMap?: AliasMap;
|
|
10
11
|
isInnerQuery?: boolean;
|
|
11
12
|
hasMultiRelationalSort?: boolean | undefined;
|
|
@@ -31,5 +32,5 @@ export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuer
|
|
|
31
32
|
hasMultiRelationalFilter: boolean;
|
|
32
33
|
};
|
|
33
34
|
export declare function addWhereClauses(knex: Knex, schema: SchemaOverview, dbQuery: Knex.QueryBuilder, filter: Filter, collection: string, aliasMap: AliasMap, logical?: 'and' | 'or'): void;
|
|
34
|
-
export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder,
|
|
35
|
+
export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, search: ASTSearch, collection: string): Promise<void>;
|
|
35
36
|
export declare function applyAggregate(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
|
|
@@ -479,24 +479,33 @@ export function addWhereClauses(knex, schema, dbQuery, filter, collection, alias
|
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
}
|
|
482
|
-
export async function applySearch(schema, dbQuery,
|
|
483
|
-
const fields =
|
|
482
|
+
export async function applySearch(schema, dbQuery, search, collection) {
|
|
483
|
+
const { fields, value: searchQuery } = search;
|
|
484
484
|
dbQuery.andWhere(function () {
|
|
485
|
-
|
|
486
|
-
|
|
485
|
+
let hasCondition = false;
|
|
486
|
+
fields.forEach((name) => {
|
|
487
|
+
const field = schema.collections[collection].fields[name];
|
|
488
|
+
if (!field || field.special.includes('conceal'))
|
|
487
489
|
return;
|
|
488
490
|
if (['text', 'string'].includes(field.type)) {
|
|
489
491
|
this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
|
|
492
|
+
hasCondition = true;
|
|
490
493
|
}
|
|
491
494
|
else if (['bigInteger', 'integer', 'decimal', 'float'].includes(field.type)) {
|
|
492
495
|
const number = Number(searchQuery);
|
|
493
|
-
if (!isNaN(number))
|
|
496
|
+
if (!isNaN(number)) {
|
|
494
497
|
this.orWhere({ [`${collection}.${name}`]: number });
|
|
498
|
+
hasCondition = true;
|
|
499
|
+
}
|
|
495
500
|
}
|
|
496
501
|
else if (field.type === 'uuid' && validate(searchQuery)) {
|
|
497
502
|
this.orWhere({ [`${collection}.${name}`]: searchQuery });
|
|
503
|
+
hasCondition = true;
|
|
498
504
|
}
|
|
499
505
|
});
|
|
506
|
+
// No field can match the search, so no item should be returned
|
|
507
|
+
if (!hasCondition)
|
|
508
|
+
this.whereRaw('1 = 0');
|
|
500
509
|
});
|
|
501
510
|
}
|
|
502
511
|
export function applyAggregate(schema, dbQuery, aggregate, collection) {
|
|
@@ -13,12 +13,6 @@ export default async function getASTFromQuery(collection, query, schema, options
|
|
|
13
13
|
return permission.action === action;
|
|
14
14
|
}) ?? []
|
|
15
15
|
: null;
|
|
16
|
-
const ast = {
|
|
17
|
-
type: 'root',
|
|
18
|
-
name: collection,
|
|
19
|
-
query: query,
|
|
20
|
-
children: [],
|
|
21
|
-
};
|
|
22
16
|
let fields = ['*'];
|
|
23
17
|
if (query.fields) {
|
|
24
18
|
fields = query.fields;
|
|
@@ -60,7 +54,13 @@ export default async function getASTFromQuery(collection, query, schema, options
|
|
|
60
54
|
if (query.aggregate && Object.keys(query.aggregate).length && !query.group?.[0]) {
|
|
61
55
|
delete query.sort;
|
|
62
56
|
}
|
|
63
|
-
|
|
57
|
+
const children = await parseFields(collection, fields, deep);
|
|
58
|
+
const ast = {
|
|
59
|
+
type: 'root',
|
|
60
|
+
name: collection,
|
|
61
|
+
query: formatSearch(collection, query),
|
|
62
|
+
children,
|
|
63
|
+
};
|
|
64
64
|
return ast;
|
|
65
65
|
async function parseFields(parentCollection, fields, deep) {
|
|
66
66
|
if (!fields)
|
|
@@ -170,7 +170,7 @@ export default async function getASTFromQuery(collection, query, schema, options
|
|
|
170
170
|
};
|
|
171
171
|
for (const relatedCollection of allowedCollections) {
|
|
172
172
|
child.children[relatedCollection] = await parseFields(relatedCollection, Array.isArray(nestedFields) ? nestedFields : nestedFields[relatedCollection] || [], deep?.[`${fieldKey}:${relatedCollection}`]);
|
|
173
|
-
child.query[relatedCollection] = getDeepQuery(deep?.[`${fieldKey}:${relatedCollection}`] || {});
|
|
173
|
+
child.query[relatedCollection] = formatSearch(relatedCollection, getDeepQuery(deep?.[`${fieldKey}:${relatedCollection}`] || {}));
|
|
174
174
|
child.relatedKey[relatedCollection] = schema.collections[relatedCollection].primary;
|
|
175
175
|
}
|
|
176
176
|
}
|
|
@@ -189,7 +189,7 @@ export default async function getASTFromQuery(collection, query, schema, options
|
|
|
189
189
|
parentKey: schema.collections[parentCollection].primary,
|
|
190
190
|
relatedKey: schema.collections[relatedCollection].primary,
|
|
191
191
|
relation: relation,
|
|
192
|
-
query: getDeepQuery(deep?.[fieldKey] || {}),
|
|
192
|
+
query: formatSearch(relatedCollection, getDeepQuery(deep?.[fieldKey] || {})),
|
|
193
193
|
children: await parseFields(relatedCollection, nestedFields, deep?.[fieldKey] || {}),
|
|
194
194
|
};
|
|
195
195
|
if (relationType === 'o2m' && !child.query.sort) {
|
|
@@ -272,6 +272,17 @@ export default async function getASTFromQuery(collection, query, schema, options
|
|
|
272
272
|
}
|
|
273
273
|
return fields;
|
|
274
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* For admins and internal calls, the search of the AST matches all the fields of the collection. For other users, it
|
|
277
|
+
* matches no field until the AuthorizationService sets the ones the role is allowed to read
|
|
278
|
+
*/
|
|
279
|
+
function formatSearch(collection, query) {
|
|
280
|
+
const { search, ...rest } = query;
|
|
281
|
+
if (!search)
|
|
282
|
+
return rest;
|
|
283
|
+
const fields = !accountability || accountability.admin === true ? Object.keys(schema.collections[collection].fields) : [];
|
|
284
|
+
return { ...rest, search: { fields, value: search } };
|
|
285
|
+
}
|
|
275
286
|
function getRelation(collection, field) {
|
|
276
287
|
const relation = schema.relations.find((relation) => {
|
|
277
288
|
return ((relation.collection === collection && relation.field === field) ||
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
147
|
+
"@wbce-d9/app": "11.3.1",
|
|
148
148
|
"@wbce-d9/constants": "10.0.0",
|
|
149
|
-
"@wbce-d9/extensions-sdk": "10.1.0",
|
|
150
149
|
"@wbce-d9/exceptions": "10.0.1",
|
|
150
|
+
"@wbce-d9/extensions-sdk": "10.1.0",
|
|
151
151
|
"@wbce-d9/schema": "10.0.0",
|
|
152
|
-
"@wbce-d9/storage": "10.0.0",
|
|
153
152
|
"@wbce-d9/specs": "9.26.5",
|
|
154
|
-
"@wbce-d9/storage-driver-cloudinary": "10.0.
|
|
153
|
+
"@wbce-d9/storage-driver-cloudinary": "10.0.7",
|
|
154
|
+
"@wbce-d9/storage": "10.0.0",
|
|
155
155
|
"@wbce-d9/storage-driver-azure": "10.0.5",
|
|
156
|
-
"@wbce-d9/storage-driver-
|
|
156
|
+
"@wbce-d9/storage-driver-gcs": "10.0.5",
|
|
157
|
+
"@wbce-d9/storage-driver-s3": "10.0.6",
|
|
157
158
|
"@wbce-d9/utils": "10.0.5",
|
|
158
|
-
"@wbce-d9/
|
|
159
|
-
"@wbce-d9/
|
|
160
|
-
"@wbce-d9/storage-driver-gcs": "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.
|
|
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.
|
|
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",
|