@appsemble/node-utils 0.36.10 → 0.37.1
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/README.md +3 -3
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +4 -4
- package/resource.d.ts +24 -3
- package/resource.js +93 -5
- package/resourceEtag.d.ts +19 -0
- package/resourceEtag.js +117 -0
- package/server/controllers/common/apps/resources/createCountAppResourcesController.js +14 -7
- package/server/controllers/common/apps/resources/createCreateAppResourceController.js +14 -5
- package/server/controllers/common/apps/resources/createDeleteAppResourceController.js +7 -2
- package/server/controllers/common/apps/resources/createGetAppResourceByIdController.js +11 -3
- package/server/controllers/common/apps/resources/createQueryAppResourcesController.js +18 -11
- package/server/controllers/common/apps/resources/createUpdateAppResourceController.js +10 -4
- package/server/routes/appRouter/indexHandler.d.ts +1 -1
- package/server/routes/appRouter/indexHandler.js +58 -11
- package/server/routes/appRouter/serviceWorkerHandler.js +12 -1
- package/server/types.d.ts +37 -5
- package/server/utils/actions.js +2 -21
- package/server/utils/resources.d.ts +24 -0
- package/server/utils/resources.js +34 -0
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
#  Appsemble Node Utilities
|
|
2
2
|
|
|
3
3
|
> NodeJS utilities used by Appsemble internally.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@appsemble/node-utils)
|
|
6
|
-
[](https://gitlab.com/appsemble/appsemble/-/releases/0.37.1)
|
|
7
7
|
[](https://prettier.io)
|
|
8
8
|
|
|
9
9
|
## Table of Contents
|
|
@@ -26,5 +26,5 @@ compatibility is not guaranteed.
|
|
|
26
26
|
|
|
27
27
|
## License
|
|
28
28
|
|
|
29
|
-
[LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.
|
|
29
|
+
[LGPL-3.0-only](https://gitlab.com/appsemble/appsemble/-/blob/0.37.1/LICENSE.md) ©
|
|
30
30
|
[Appsemble](https://appsemble.com)
|
package/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export * from './render.js';
|
|
|
21
21
|
export * from './getAppsembleMessages.js';
|
|
22
22
|
export * from './odata.js';
|
|
23
23
|
export * from './resource.js';
|
|
24
|
+
export * from './resourceEtag.js';
|
|
24
25
|
export * from './app.js';
|
|
25
26
|
export * from './uploadValidation.js';
|
|
26
27
|
export * from './mergeMessages.js';
|
|
@@ -48,3 +49,4 @@ export * from './createUser.js';
|
|
|
48
49
|
export * from './deleteUser.js';
|
|
49
50
|
export * from './updateSubscription.js';
|
|
50
51
|
export * from './server/utils/ssrf.js';
|
|
52
|
+
export { appWideGroupId, getSingleGroupId } from './server/utils/resources.js';
|
package/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export * from './render.js';
|
|
|
21
21
|
export * from './getAppsembleMessages.js';
|
|
22
22
|
export * from './odata.js';
|
|
23
23
|
export * from './resource.js';
|
|
24
|
+
export * from './resourceEtag.js';
|
|
24
25
|
export * from './app.js';
|
|
25
26
|
export * from './uploadValidation.js';
|
|
26
27
|
export * from './mergeMessages.js';
|
|
@@ -48,4 +49,5 @@ export * from './createUser.js';
|
|
|
48
49
|
export * from './deleteUser.js';
|
|
49
50
|
export * from './updateSubscription.js';
|
|
50
51
|
export * from './server/utils/ssrf.js';
|
|
52
|
+
export { appWideGroupId, getSingleGroupId } from './server/utils/resources.js';
|
|
51
53
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appsemble/node-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"description": "NodeJS utilities used by Appsemble internally.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"test": "vitest"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@appsemble/lang-sdk": "0.
|
|
44
|
-
"@appsemble/types": "0.
|
|
45
|
-
"@appsemble/utils": "0.
|
|
43
|
+
"@appsemble/lang-sdk": "0.37.1",
|
|
44
|
+
"@appsemble/types": "0.37.1",
|
|
45
|
+
"@appsemble/utils": "0.37.1",
|
|
46
46
|
"@formatjs/fast-memoize": "^2.0.0",
|
|
47
47
|
"@fortawesome/fontawesome-free": "^6.0.0",
|
|
48
48
|
"@inquirer/prompts": "^8.0.0",
|
package/resource.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type AppDefinition, type ResourceDefinition } from '@appsemble/lang-sdk';
|
|
2
|
-
import { type Resource as ResourceType } from '@appsemble/types';
|
|
2
|
+
import { type App, type Resource as ResourceType } from '@appsemble/types';
|
|
3
3
|
import { type PreValidatePropertyFunction } from 'jsonschema';
|
|
4
4
|
import { type Context, type DefaultContext, type DefaultState, type ParameterizedContext } from 'koa';
|
|
5
5
|
import { type JsonValue } from 'type-fest';
|
|
6
|
-
import { type PreparedAsset, TempFile } from './index.js';
|
|
7
|
-
export declare function stripResource({ $author, $created, $editor, $ephemeral, $group, $seed, $updated, ...data }: ResourceType): Record<string, unknown>;
|
|
6
|
+
import { type GetAppResourcesParams, type PreparedAsset, TempFile } from './index.js';
|
|
7
|
+
export declare function stripResource({ $author, $created, $editor, $ephemeral, $etag, $group, $seed, $updated, ...data }: ResourceType): Record<string, unknown>;
|
|
8
8
|
/**
|
|
9
9
|
* Works on a resource, which has been processed on the server by the streamParser
|
|
10
10
|
*
|
|
@@ -17,6 +17,11 @@ export declare function serializeServerResource(data: any): JsonValue | {
|
|
|
17
17
|
assets: TempFile[];
|
|
18
18
|
};
|
|
19
19
|
export type SerializedServerResourceBody = ReturnType<typeof serializeServerResource>;
|
|
20
|
+
export type SerializedMultipartBody = Extract<SerializedServerResourceBody, {
|
|
21
|
+
resource: JsonValue;
|
|
22
|
+
assets: TempFile[];
|
|
23
|
+
}>;
|
|
24
|
+
export declare function isSerializedMultipartBody(value: SerializedServerResourceBody): value is SerializedMultipartBody;
|
|
20
25
|
/**
|
|
21
26
|
* Get the resource definition of an app by name.
|
|
22
27
|
*
|
|
@@ -68,3 +73,19 @@ export declare function processResourceBody(ctx: Context | ParameterizedContext<
|
|
|
68
73
|
id: string;
|
|
69
74
|
name?: string;
|
|
70
75
|
}[], isPatch?: boolean, resourceBody?: SerializedServerResourceBody): Promise<[Record<string, unknown> | Record<string, unknown>[], PreparedAsset[], string[]]>;
|
|
76
|
+
/**
|
|
77
|
+
* Validate that the references declared in the resource definition point at existing resources.
|
|
78
|
+
*
|
|
79
|
+
* Reference properties which hold no value are skipped. Expired resources are treated as
|
|
80
|
+
* non-existent. The referenced ids are collected per referenced resource type, so each type is
|
|
81
|
+
* checked using a single lookup, even when multiple resources are submitted at once.
|
|
82
|
+
*
|
|
83
|
+
* If a reference is broken, an HTTP 400 error naming the offending property path is thrown.
|
|
84
|
+
*
|
|
85
|
+
* @param ctx The Koa context used to throw back the error response.
|
|
86
|
+
* @param app The app the resources belong to.
|
|
87
|
+
* @param definition The definition of the resource type being created or updated.
|
|
88
|
+
* @param processedBody One or more resources as processed by `processResourceBody`.
|
|
89
|
+
* @param getAppResources The function used to look up resources of the referenced types.
|
|
90
|
+
*/
|
|
91
|
+
export declare function validateResourceReferences(ctx: Context, app: App, definition: ResourceDefinition, processedBody: Record<string, unknown> | Record<string, unknown>[], getAppResources: (params: GetAppResourcesParams) => Promise<ResourceType[]>): Promise<void>;
|
package/resource.js
CHANGED
|
@@ -5,10 +5,10 @@ import { ValidationError, Validator } from 'jsonschema';
|
|
|
5
5
|
import { partition } from 'lodash-es';
|
|
6
6
|
import parseDuration from 'parse-duration';
|
|
7
7
|
import { preProcessCSV } from './csv.js';
|
|
8
|
-
import { handleValidatorResult, TempFile } from './index.js';
|
|
8
|
+
import { handleValidatorResult, TempFile, } from './index.js';
|
|
9
9
|
import { throwKoaError } from './koa.js';
|
|
10
10
|
import { AssetUploadValidationError, validateUploadedFile } from './uploadValidation.js';
|
|
11
|
-
export function stripResource({ $author, $created, $editor, $ephemeral, $group, $seed, $updated, ...data }) {
|
|
11
|
+
export function stripResource({ $author, $created, $editor, $ephemeral, $etag, $group, $seed, $updated, ...data }) {
|
|
12
12
|
return data;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
@@ -44,7 +44,7 @@ export function serializeServerResource(data) {
|
|
|
44
44
|
assets,
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
|
-
function isSerializedMultipartBody(value) {
|
|
47
|
+
export function isSerializedMultipartBody(value) {
|
|
48
48
|
return (value != null &&
|
|
49
49
|
typeof value === 'object' &&
|
|
50
50
|
!Array.isArray(value) &&
|
|
@@ -232,9 +232,10 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
|
|
|
232
232
|
}
|
|
233
233
|
return num >= 0 && num < assets.length;
|
|
234
234
|
};
|
|
235
|
+
const isPatchRequest = ctx.request?.method === 'PATCH' || isPatch;
|
|
235
236
|
const patchedSchema = {
|
|
236
237
|
...definition.schema,
|
|
237
|
-
required:
|
|
238
|
+
required: isPatchRequest ? [] : definition.schema.required,
|
|
238
239
|
properties: {
|
|
239
240
|
...definition.schema.properties,
|
|
240
241
|
id: { type: 'integer' },
|
|
@@ -266,7 +267,8 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
|
|
|
266
267
|
base: '#',
|
|
267
268
|
preValidateProperty,
|
|
268
269
|
nestedErrors: true,
|
|
269
|
-
rewrite(value,
|
|
270
|
+
rewrite(value, propertySchema, options, { path }) {
|
|
271
|
+
const { format, oneOf } = propertySchema;
|
|
270
272
|
let propertyName;
|
|
271
273
|
if (Array.isArray(resource) && path.length === 2 && typeof path[0] === 'number') {
|
|
272
274
|
propertyName = path[1];
|
|
@@ -302,6 +304,14 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
|
|
|
302
304
|
if (propertyName === '$clonable') {
|
|
303
305
|
return definition.clonable;
|
|
304
306
|
}
|
|
307
|
+
// Materialize schema property defaults for missing values. Patch requests skip this,
|
|
308
|
+
// because a missing property there means the stored value is kept.
|
|
309
|
+
const defaultValue = propertySchema.default;
|
|
310
|
+
if (!isPatchRequest && defaultValue !== undefined) {
|
|
311
|
+
return typeof defaultValue === 'object' && defaultValue != null
|
|
312
|
+
? structuredClone(defaultValue)
|
|
313
|
+
: defaultValue;
|
|
314
|
+
}
|
|
305
315
|
return;
|
|
306
316
|
}
|
|
307
317
|
if (format !== 'binary' && !oneOf?.some((s) => s.format === 'binary')) {
|
|
@@ -334,4 +344,82 @@ export async function processResourceBody(ctx, definition, knownAssetIds = [], k
|
|
|
334
344
|
handleValidatorResult(ctx, result, 'Resource validation failed');
|
|
335
345
|
return [resource, preparedAssets, knownAssetIds.filter((id) => !reusedAssets.has(id))];
|
|
336
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Validate that the references declared in the resource definition point at existing resources.
|
|
349
|
+
*
|
|
350
|
+
* Reference properties which hold no value are skipped. Expired resources are treated as
|
|
351
|
+
* non-existent. The referenced ids are collected per referenced resource type, so each type is
|
|
352
|
+
* checked using a single lookup, even when multiple resources are submitted at once.
|
|
353
|
+
*
|
|
354
|
+
* If a reference is broken, an HTTP 400 error naming the offending property path is thrown.
|
|
355
|
+
*
|
|
356
|
+
* @param ctx The Koa context used to throw back the error response.
|
|
357
|
+
* @param app The app the resources belong to.
|
|
358
|
+
* @param definition The definition of the resource type being created or updated.
|
|
359
|
+
* @param processedBody One or more resources as processed by `processResourceBody`.
|
|
360
|
+
* @param getAppResources The function used to look up resources of the referenced types.
|
|
361
|
+
*/
|
|
362
|
+
export async function validateResourceReferences(ctx, app, definition, processedBody, getAppResources) {
|
|
363
|
+
const references = Object.entries(definition.references ?? {});
|
|
364
|
+
if (!references.length) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
|
|
368
|
+
const referencedIds = new Map();
|
|
369
|
+
for (const [propertyName, reference] of references) {
|
|
370
|
+
for (const resource of resources) {
|
|
371
|
+
for (const value of [resource[propertyName]].flat()) {
|
|
372
|
+
if (value == null || value === '') {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const id = typeof value === 'string' ? Number(value) : value;
|
|
376
|
+
if (typeof id === 'number' && Number.isInteger(id)) {
|
|
377
|
+
if (!referencedIds.has(reference.resource)) {
|
|
378
|
+
referencedIds.set(reference.resource, new Set());
|
|
379
|
+
}
|
|
380
|
+
referencedIds.get(reference.resource).add(id);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const existingIds = new Map();
|
|
386
|
+
await Promise.all([...referencedIds].map(async ([type, ids]) => {
|
|
387
|
+
const existing = await getAppResources({
|
|
388
|
+
app,
|
|
389
|
+
context: ctx,
|
|
390
|
+
type,
|
|
391
|
+
findOptions: {
|
|
392
|
+
where: {
|
|
393
|
+
and: [
|
|
394
|
+
{ type },
|
|
395
|
+
{ or: [...ids].map((id) => ({ id })) },
|
|
396
|
+
{ expires: { or: [{ gt: new Date() }, null] } },
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
existingIds.set(type, new Set(existing.map((resource) => Number(resource.id))));
|
|
402
|
+
}));
|
|
403
|
+
const errors = [];
|
|
404
|
+
for (const [propertyName, reference] of references) {
|
|
405
|
+
for (const [index, resource] of resources.entries()) {
|
|
406
|
+
for (const value of [resource[propertyName]].flat()) {
|
|
407
|
+
if (value == null || value === '') {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const id = typeof value === 'string' ? Number(value) : value;
|
|
411
|
+
if (typeof id === 'number' && existingIds.get(reference.resource)?.has(id)) {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
errors.push(new ValidationError(`does not reference an existing resource of type ${reference.resource}`, value, undefined, Array.isArray(processedBody) ? [index, propertyName] : [propertyName]));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (errors.length) {
|
|
419
|
+
if (ctx.response === undefined) {
|
|
420
|
+
throw new Error('Resource validation failed', { cause: errors });
|
|
421
|
+
}
|
|
422
|
+
throwKoaError(ctx, 400, 'Resource validation failed', { errors });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
337
425
|
//# sourceMappingURL=resource.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Context } from 'koa';
|
|
2
|
+
import { AppsembleError } from './AppsembleError.js';
|
|
3
|
+
export declare function createResourceEtag(resource: Record<string, unknown>): string;
|
|
4
|
+
export declare function addResourceEtag<T extends Record<string, unknown>>(resource: T): T & {
|
|
5
|
+
$etag: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function matchesResourceIfMatch(ifMatch: string | string[] | undefined, currentEtag: string): boolean;
|
|
8
|
+
export declare function setResourceEtagHeader(ctx: Context, resource: Record<string, unknown> | null | undefined): void;
|
|
9
|
+
export declare class ResourcePreconditionFailedError extends AppsembleError {
|
|
10
|
+
readonly data: {
|
|
11
|
+
code: 'RESOURCE_PRECONDITION_FAILED';
|
|
12
|
+
resourceId: number | string;
|
|
13
|
+
resourceType: string;
|
|
14
|
+
};
|
|
15
|
+
readonly error = "Precondition Failed";
|
|
16
|
+
readonly statusCode = 412;
|
|
17
|
+
constructor(resourceType: string, resourceId: number | string);
|
|
18
|
+
}
|
|
19
|
+
export declare function throwResourcePreconditionFailedKoaError(ctx: Context, resourceType: string, resourceId: number | string): never;
|
package/resourceEtag.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import sortKeys from 'sort-keys';
|
|
3
|
+
import { AppsembleError } from './AppsembleError.js';
|
|
4
|
+
import { throwKoaError } from './koa.js';
|
|
5
|
+
// Server-managed metadata keys that are echoed in resource responses but are not
|
|
6
|
+
// part of the resource's identity for ETag purposes. Stripped only at the top
|
|
7
|
+
// level; nested user data may legitimately contain keys with the same names.
|
|
8
|
+
const ignoredEtagFields = new Set(['$author', '$editor', '$etag', '$group', '$seed', '$ephemeral']);
|
|
9
|
+
function canonicalize(value) {
|
|
10
|
+
if (value instanceof Date) {
|
|
11
|
+
return value.toJSON();
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return value.map(canonicalize);
|
|
15
|
+
}
|
|
16
|
+
if (value && typeof value === 'object') {
|
|
17
|
+
return sortKeys(Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
18
|
+
key,
|
|
19
|
+
canonicalize(entry),
|
|
20
|
+
])), { deep: true });
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
export function createResourceEtag(resource) {
|
|
25
|
+
const stripped = {};
|
|
26
|
+
for (const [key, value] of Object.entries(resource)) {
|
|
27
|
+
if (!ignoredEtagFields.has(key)) {
|
|
28
|
+
stripped[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const canonicalResource = JSON.stringify(canonicalize(stripped));
|
|
32
|
+
const hash = createHash('sha256').update(canonicalResource, 'utf8').digest('base64url');
|
|
33
|
+
return `"${hash}"`;
|
|
34
|
+
}
|
|
35
|
+
export function addResourceEtag(resource) {
|
|
36
|
+
return {
|
|
37
|
+
...resource,
|
|
38
|
+
$etag: createResourceEtag(resource),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// RFC 7232 §3.1: If-Match uses the strong-comparison function, so weak
|
|
42
|
+
// validators (prefixed with `W/`) must not match. Tokens are split with a
|
|
43
|
+
// quoted-string-aware parser instead of a naive `,` split.
|
|
44
|
+
function parseIfMatchValue(ifMatch) {
|
|
45
|
+
if (ifMatch === '*') {
|
|
46
|
+
return ['*'];
|
|
47
|
+
}
|
|
48
|
+
const tokens = [];
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < ifMatch.length) {
|
|
51
|
+
while (i < ifMatch.length &&
|
|
52
|
+
(ifMatch[i] === ' ' || ifMatch[i] === ',' || ifMatch[i] === '\t')) {
|
|
53
|
+
i += 1;
|
|
54
|
+
}
|
|
55
|
+
if (i >= ifMatch.length) {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
// Skip any leading weak prefix; strong comparison rejects it.
|
|
59
|
+
let weak = false;
|
|
60
|
+
if (ifMatch[i] === 'W' && ifMatch[i + 1] === '/') {
|
|
61
|
+
weak = true;
|
|
62
|
+
i += 2;
|
|
63
|
+
}
|
|
64
|
+
if (ifMatch[i] !== '"') {
|
|
65
|
+
// Malformed token; skip until next comma.
|
|
66
|
+
while (i < ifMatch.length && ifMatch[i] !== ',') {
|
|
67
|
+
i += 1;
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const start = i;
|
|
72
|
+
i += 1;
|
|
73
|
+
while (i < ifMatch.length && ifMatch[i] !== '"') {
|
|
74
|
+
i += 1;
|
|
75
|
+
}
|
|
76
|
+
if (i >= ifMatch.length) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
i += 1;
|
|
80
|
+
if (!weak) {
|
|
81
|
+
tokens.push(ifMatch.slice(start, i));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return tokens;
|
|
85
|
+
}
|
|
86
|
+
export function matchesResourceIfMatch(ifMatch, currentEtag) {
|
|
87
|
+
if (!ifMatch) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const values = new Set((Array.isArray(ifMatch) ? ifMatch : [ifMatch]).flatMap(parseIfMatchValue));
|
|
91
|
+
return values.has('*') || values.has(currentEtag);
|
|
92
|
+
}
|
|
93
|
+
export function setResourceEtagHeader(ctx, resource) {
|
|
94
|
+
if (!resource) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const etag = typeof resource.$etag === 'string' ? resource.$etag : createResourceEtag(resource);
|
|
98
|
+
ctx.set('ETag', etag);
|
|
99
|
+
}
|
|
100
|
+
export class ResourcePreconditionFailedError extends AppsembleError {
|
|
101
|
+
constructor(resourceType, resourceId) {
|
|
102
|
+
super('This resource has changed since it was loaded. Fetch the latest version and try again.');
|
|
103
|
+
this.error = 'Precondition Failed';
|
|
104
|
+
this.statusCode = 412;
|
|
105
|
+
this.name = 'ResourcePreconditionFailedError';
|
|
106
|
+
this.data = {
|
|
107
|
+
code: 'RESOURCE_PRECONDITION_FAILED',
|
|
108
|
+
resourceId,
|
|
109
|
+
resourceType,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function throwResourcePreconditionFailedKoaError(ctx, resourceType, resourceId) {
|
|
114
|
+
const error = new ResourcePreconditionFailedError(resourceType, resourceId);
|
|
115
|
+
throwKoaError(ctx, error.statusCode, error.message, error.data);
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=resourceEtag.js.map
|
|
@@ -1,20 +1,27 @@
|
|
|
1
|
-
import { generateResourceQuery } from '../../../../utils/resources.js';
|
|
1
|
+
import { generateResourceQuery, getGroupIdWhere } from '../../../../utils/resources.js';
|
|
2
2
|
export function createCountAppResourcesController(options) {
|
|
3
3
|
return async (ctx) => {
|
|
4
4
|
const { pathParams: { appId, resourceType }, queryParams: { $own, selectedGroupId }, user: authSubject, } = ctx;
|
|
5
|
-
const { checkAppPermissions, getApp, getAppResources } = options;
|
|
5
|
+
const { checkAppPermissions, getAllowedGroups, getApp, getAppResources } = options;
|
|
6
6
|
const app = await getApp({
|
|
7
7
|
context: ctx,
|
|
8
8
|
query: { where: { id: appId }, attributes: ['demoMode', 'id'] },
|
|
9
9
|
});
|
|
10
|
-
|
|
10
|
+
const permissions = [
|
|
11
|
+
$own ? `$resource:${resourceType}:own:query` : `$resource:${resourceType}:query`,
|
|
12
|
+
];
|
|
13
|
+
// Across multiple selected groups the permission acts as a filter: only the
|
|
14
|
+
// groups the subject may query are counted. When no selected group is
|
|
15
|
+
// queryable, the strict permission check throws its usual 403.
|
|
16
|
+
const allowedGroups = await getAllowedGroups({
|
|
11
17
|
context: ctx,
|
|
12
|
-
permissions
|
|
13
|
-
$own ? `$resource:${resourceType}:own:query` : `$resource:${resourceType}:query`,
|
|
14
|
-
],
|
|
18
|
+
permissions,
|
|
15
19
|
app,
|
|
16
20
|
groupId: selectedGroupId,
|
|
17
21
|
});
|
|
22
|
+
if (!allowedGroups.length) {
|
|
23
|
+
await checkAppPermissions({ context: ctx, permissions, app, groupId: selectedGroupId });
|
|
24
|
+
}
|
|
18
25
|
const { where } = generateResourceQuery(ctx, options);
|
|
19
26
|
const findOptions = {
|
|
20
27
|
attributes: ['id'],
|
|
@@ -23,7 +30,7 @@ export function createCountAppResourcesController(options) {
|
|
|
23
30
|
where || {},
|
|
24
31
|
{
|
|
25
32
|
type: resourceType,
|
|
26
|
-
GroupId:
|
|
33
|
+
GroupId: getGroupIdWhere(allowedGroups.length ? allowedGroups : selectedGroupId),
|
|
27
34
|
expires: { or: [{ gt: new Date() }, null] },
|
|
28
35
|
...(app.demoMode ? { seed: false, ephemeral: true } : {}),
|
|
29
36
|
...($own ? { AuthorId: authSubject?.id } : {}),
|
|
@@ -3,7 +3,7 @@ import { copyFile } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
-
import { getResourceDefinition, processResourceBody, } from '../../../../../index.js';
|
|
6
|
+
import { getResourceDefinition, getSingleGroupId, processResourceBody, setResourceEtagHeader, validateResourceReferences, } from '../../../../../index.js';
|
|
7
7
|
function clonePreparedAssets(preparedAssets) {
|
|
8
8
|
return Promise.all(preparedAssets.map(async (asset) => {
|
|
9
9
|
const path = join(tmpdir(), `${Date.now()}-${randomUUID()}`);
|
|
@@ -26,7 +26,8 @@ function clonePreparedAssets(preparedAssets) {
|
|
|
26
26
|
export function createCreateAppResourceController(options) {
|
|
27
27
|
return async (ctx) => {
|
|
28
28
|
const { pathParams: { appId, resourceType }, query, queryParams: { selectedGroupId }, } = ctx;
|
|
29
|
-
const
|
|
29
|
+
const groupId = getSingleGroupId(selectedGroupId);
|
|
30
|
+
const { checkAppPermissions, createAppResourcesWithAssets, getApp, getAppAssets, getAppResources, } = options;
|
|
30
31
|
const app = await getApp({
|
|
31
32
|
context: ctx,
|
|
32
33
|
query: { attributes: ['id', 'demoMode', 'definition'], where: { id: appId } },
|
|
@@ -35,7 +36,8 @@ export function createCreateAppResourceController(options) {
|
|
|
35
36
|
context: ctx,
|
|
36
37
|
permissions: [`$resource:${resourceType}:create`],
|
|
37
38
|
app,
|
|
38
|
-
|
|
39
|
+
// The resource is created in a single group; authorize against that group.
|
|
40
|
+
groupId,
|
|
39
41
|
});
|
|
40
42
|
const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx);
|
|
41
43
|
const appAssets = await getAppAssets({ app, context: ctx });
|
|
@@ -44,6 +46,7 @@ export function createCreateAppResourceController(options) {
|
|
|
44
46
|
ctx.body = [];
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
49
|
+
await validateResourceReferences(ctx, app, resourceDefinition, processedBody, getAppResources);
|
|
47
50
|
const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
|
|
48
51
|
if (!(ctx.client && 'app' in ctx.client) && query?.seed === 'true') {
|
|
49
52
|
const preparedSeedAssets = app.demoMode
|
|
@@ -77,7 +80,7 @@ export function createCreateAppResourceController(options) {
|
|
|
77
80
|
}
|
|
78
81
|
const createdSeedResources = await createAppResourcesWithAssets({
|
|
79
82
|
app,
|
|
80
|
-
groupId
|
|
83
|
+
groupId,
|
|
81
84
|
context: ctx,
|
|
82
85
|
resources: preparedSeedResources,
|
|
83
86
|
preparedAssets: preparedSeedAssets,
|
|
@@ -86,12 +89,15 @@ export function createCreateAppResourceController(options) {
|
|
|
86
89
|
});
|
|
87
90
|
if (!app.demoMode) {
|
|
88
91
|
ctx.body = Array.isArray(processedBody) ? createdSeedResources : createdSeedResources[0];
|
|
92
|
+
if (!Array.isArray(ctx.body)) {
|
|
93
|
+
setResourceEtagHeader(ctx, ctx.body);
|
|
94
|
+
}
|
|
89
95
|
return;
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
const createdResources = await createAppResourcesWithAssets({
|
|
93
99
|
app,
|
|
94
|
-
groupId
|
|
100
|
+
groupId,
|
|
95
101
|
context: ctx,
|
|
96
102
|
resources: resources.map((resource) => ({
|
|
97
103
|
...resource,
|
|
@@ -104,6 +110,9 @@ export function createCreateAppResourceController(options) {
|
|
|
104
110
|
options,
|
|
105
111
|
});
|
|
106
112
|
ctx.body = Array.isArray(processedBody) ? createdResources : createdResources[0];
|
|
113
|
+
if (!Array.isArray(ctx.body)) {
|
|
114
|
+
setResourceEtagHeader(ctx, ctx.body);
|
|
115
|
+
}
|
|
107
116
|
};
|
|
108
117
|
}
|
|
109
118
|
//# sourceMappingURL=createCreateAppResourceController.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertKoaCondition } from '@appsemble/node-utils';
|
|
2
|
+
import { appWideGroupId, getGroupIdWhere } from '../../../../utils/resources.js';
|
|
2
3
|
export function createDeleteAppResourceController(options) {
|
|
3
4
|
return async (ctx) => {
|
|
4
5
|
const { pathParams: { appId, resourceId, resourceType }, queryParams: { selectedGroupId }, user: authSubject, } = ctx;
|
|
@@ -11,7 +12,7 @@ export function createDeleteAppResourceController(options) {
|
|
|
11
12
|
where: {
|
|
12
13
|
id: resourceId,
|
|
13
14
|
type: resourceType,
|
|
14
|
-
GroupId: selectedGroupId
|
|
15
|
+
GroupId: getGroupIdWhere(selectedGroupId),
|
|
15
16
|
expires: { or: [{ gt: new Date() }, { eq: null }] },
|
|
16
17
|
...(app.demoMode ? { seed: false, ephemeral: true } : {}),
|
|
17
18
|
},
|
|
@@ -24,6 +25,8 @@ export function createDeleteAppResourceController(options) {
|
|
|
24
25
|
findOptions,
|
|
25
26
|
});
|
|
26
27
|
assertKoaCondition(resource != null, ctx, 404, 'Resource not found');
|
|
28
|
+
// The resource is searched across the selected groups; authorization is
|
|
29
|
+
// then scoped to the group the resource actually belongs to.
|
|
27
30
|
await checkAppPermissions({
|
|
28
31
|
context: ctx,
|
|
29
32
|
permissions: [
|
|
@@ -32,7 +35,7 @@ export function createDeleteAppResourceController(options) {
|
|
|
32
35
|
: `$resource:${resourceType}:delete`,
|
|
33
36
|
],
|
|
34
37
|
app,
|
|
35
|
-
groupId:
|
|
38
|
+
groupId: resource.$group?.id ?? appWideGroupId,
|
|
36
39
|
});
|
|
37
40
|
await deleteAppResource({
|
|
38
41
|
app,
|
|
@@ -40,6 +43,8 @@ export function createDeleteAppResourceController(options) {
|
|
|
40
43
|
id: resourceId,
|
|
41
44
|
type: resourceType,
|
|
42
45
|
options,
|
|
46
|
+
lockWhere: findOptions.where,
|
|
47
|
+
ifMatch: ctx.get('If-Match') || undefined,
|
|
43
48
|
});
|
|
44
49
|
ctx.status = 204;
|
|
45
50
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defaultLocale, remap } from '@appsemble/lang-sdk';
|
|
2
|
-
import { assertKoaCondition, getRemapperContext, getResourceDefinition, } from '@appsemble/node-utils';
|
|
2
|
+
import { assertKoaCondition, getRemapperContext, getResourceDefinition, setResourceEtagHeader, } from '@appsemble/node-utils';
|
|
3
|
+
import { appWideGroupId, getGroupIdWhere } from '../../../../utils/resources.js';
|
|
3
4
|
export function createGetAppResourceByIdController(options) {
|
|
4
5
|
return async (ctx) => {
|
|
5
6
|
const { pathParams: { appId, resourceId, resourceType }, queryParams: { selectedGroupId, view }, user: authSubject, } = ctx;
|
|
@@ -12,7 +13,7 @@ export function createGetAppResourceByIdController(options) {
|
|
|
12
13
|
where: {
|
|
13
14
|
id: resourceId,
|
|
14
15
|
type: resourceType,
|
|
15
|
-
GroupId: selectedGroupId
|
|
16
|
+
GroupId: getGroupIdWhere(selectedGroupId),
|
|
16
17
|
expires: { or: [{ gt: new Date() }, null] },
|
|
17
18
|
...(app.demoMode ? { seed: false, ephemeral: true } : {}),
|
|
18
19
|
},
|
|
@@ -25,6 +26,8 @@ export function createGetAppResourceByIdController(options) {
|
|
|
25
26
|
findOptions,
|
|
26
27
|
});
|
|
27
28
|
assertKoaCondition(resource != null, ctx, 404, 'Resource not found');
|
|
29
|
+
// The resource is searched across the selected groups; authorization is
|
|
30
|
+
// then scoped to the group the resource actually belongs to.
|
|
28
31
|
await checkAppPermissions({
|
|
29
32
|
context: ctx,
|
|
30
33
|
permissions: [
|
|
@@ -35,14 +38,19 @@ export function createGetAppResourceByIdController(options) {
|
|
|
35
38
|
: `$resource:${resourceType}:get`,
|
|
36
39
|
],
|
|
37
40
|
app,
|
|
38
|
-
groupId:
|
|
41
|
+
groupId: resource.$group?.id ?? appWideGroupId,
|
|
39
42
|
});
|
|
40
43
|
if (view) {
|
|
41
44
|
const context = await getRemapperContext(app, app.definition.defaultLanguage || defaultLocale, options, ctx);
|
|
42
45
|
const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx, view);
|
|
46
|
+
// No ETag for view responses: the response body is a remapped projection
|
|
47
|
+
// that does not uniquely identify the raw resource representation, so a
|
|
48
|
+
// shared ETag across views would violate RFC 7232's representation-
|
|
49
|
+
// identity requirement and confuse conditional GETs/caches.
|
|
43
50
|
ctx.body = remap(resourceDefinition.views?.[view].remap ?? null, resource, context);
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
53
|
+
setResourceEtagHeader(ctx, resource);
|
|
46
54
|
ctx.body = resource;
|
|
47
55
|
};
|
|
48
56
|
}
|
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
import { defaultLocale, remap } from '@appsemble/lang-sdk';
|
|
2
2
|
import { getRemapperContext, getResourceDefinition, } from '@appsemble/node-utils';
|
|
3
|
-
import { generateResourceQuery } from '../../../../utils/resources.js';
|
|
3
|
+
import { generateResourceQuery, getGroupIdWhere } from '../../../../utils/resources.js';
|
|
4
4
|
export function createQueryAppResourcesController(options) {
|
|
5
5
|
return async (ctx) => {
|
|
6
6
|
const { pathParams: { appId, resourceType }, queryParams: { $own, $select, $skip, $top, selectedGroupId, view }, user: authSubject, } = ctx;
|
|
7
|
-
const { checkAppPermissions, getApp, getAppResources } = options;
|
|
7
|
+
const { checkAppPermissions, getAllowedGroups, getApp, getAppResources } = options;
|
|
8
8
|
const app = await getApp({
|
|
9
9
|
context: ctx,
|
|
10
10
|
query: { attributes: ['definition', 'demoMode', 'id', 'template'], where: { id: appId } },
|
|
11
11
|
});
|
|
12
12
|
const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx, view);
|
|
13
13
|
const { order, where } = generateResourceQuery(ctx, options, resourceDefinition);
|
|
14
|
-
|
|
14
|
+
const permissions = [
|
|
15
|
+
$own
|
|
16
|
+
? `$resource:${resourceType}:own:query`
|
|
17
|
+
: view
|
|
18
|
+
? `$resource:${resourceType}:query:${view}`
|
|
19
|
+
: `$resource:${resourceType}:query`,
|
|
20
|
+
];
|
|
21
|
+
// Across multiple selected groups the permission acts as a filter: only the
|
|
22
|
+
// groups the subject may query are returned. When no selected group is
|
|
23
|
+
// queryable, the strict permission check throws its usual 403.
|
|
24
|
+
const allowedGroups = await getAllowedGroups({
|
|
15
25
|
context: ctx,
|
|
16
|
-
permissions
|
|
17
|
-
$own
|
|
18
|
-
? `$resource:${resourceType}:own:query`
|
|
19
|
-
: view
|
|
20
|
-
? `$resource:${resourceType}:query:${view}`
|
|
21
|
-
: `$resource:${resourceType}:query`,
|
|
22
|
-
],
|
|
26
|
+
permissions,
|
|
23
27
|
app,
|
|
24
28
|
groupId: selectedGroupId,
|
|
25
29
|
});
|
|
30
|
+
if (!allowedGroups.length) {
|
|
31
|
+
await checkAppPermissions({ context: ctx, permissions, app, groupId: selectedGroupId });
|
|
32
|
+
}
|
|
26
33
|
const isSameOrigin = ctx?.headers?.origin === ctx?.headers?.host;
|
|
27
34
|
const findOptions = {
|
|
28
35
|
limit: $top,
|
|
@@ -33,7 +40,7 @@ export function createQueryAppResourcesController(options) {
|
|
|
33
40
|
where,
|
|
34
41
|
{
|
|
35
42
|
type: resourceType,
|
|
36
|
-
GroupId:
|
|
43
|
+
GroupId: getGroupIdWhere(allowedGroups.length ? allowedGroups : selectedGroupId),
|
|
37
44
|
expires: { or: [{ gt: new Date() }, null] },
|
|
38
45
|
...(app.demoMode && !isSameOrigin ? { seed: false, ephemeral: true } : {}),
|
|
39
46
|
...($own ? { AuthorId: authSubject?.id } : {}),
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { assertKoaCondition, getResourceDefinition, processResourceBody, } from '@appsemble/node-utils';
|
|
1
|
+
import { assertKoaCondition, getResourceDefinition, getSingleGroupId, processResourceBody, setResourceEtagHeader, validateResourceReferences, } from '@appsemble/node-utils';
|
|
2
2
|
export function createUpdateAppResourceController(options) {
|
|
3
3
|
return async (ctx) => {
|
|
4
4
|
const { pathParams: { appId, resourceId, resourceType }, queryParams: { selectedGroupId }, user: authSubject, } = ctx;
|
|
5
|
-
const
|
|
5
|
+
const groupId = getSingleGroupId(selectedGroupId);
|
|
6
|
+
const { checkAppPermissions, getApp, getAppAssets, getAppResource, getAppResources, updateAppResource, } = options;
|
|
6
7
|
const app = await getApp({
|
|
7
8
|
context: ctx,
|
|
8
9
|
query: { attributes: ['id', 'demoMode', 'definition', 'template'], where: { id: appId } },
|
|
@@ -11,7 +12,7 @@ export function createUpdateAppResourceController(options) {
|
|
|
11
12
|
where: {
|
|
12
13
|
id: resourceId,
|
|
13
14
|
type: resourceType,
|
|
14
|
-
GroupId:
|
|
15
|
+
GroupId: groupId,
|
|
15
16
|
expires: { or: [{ gt: new Date() }, null] },
|
|
16
17
|
...(app.demoMode ? { seed: false, ephemeral: true } : {}),
|
|
17
18
|
},
|
|
@@ -32,11 +33,13 @@ export function createUpdateAppResourceController(options) {
|
|
|
32
33
|
: `$resource:${resourceType}:update`,
|
|
33
34
|
],
|
|
34
35
|
app,
|
|
35
|
-
|
|
36
|
+
// The operation acts on a single group; authorize against that group only.
|
|
37
|
+
groupId,
|
|
36
38
|
});
|
|
37
39
|
const appAssets = await getAppAssets({ context: ctx, app });
|
|
38
40
|
const resourceDefinition = getResourceDefinition(app.definition, resourceType, ctx);
|
|
39
41
|
const [processedBody, preparedAssets, deletedAssetIds] = await processResourceBody(ctx, resourceDefinition, appAssets.filter((asset) => asset.resourceId === resourceId).map((asset) => asset.id), oldResource.expires, appAssets.map((asset) => ({ id: asset.id, name: asset.name })));
|
|
42
|
+
await validateResourceReferences(ctx, app, resourceDefinition, processedBody, getAppResources);
|
|
40
43
|
const resources = Array.isArray(processedBody) ? processedBody : [processedBody];
|
|
41
44
|
ctx.body = await updateAppResource({
|
|
42
45
|
app,
|
|
@@ -48,7 +51,10 @@ export function createUpdateAppResourceController(options) {
|
|
|
48
51
|
deletedAssetIds,
|
|
49
52
|
resourceDefinition,
|
|
50
53
|
options,
|
|
54
|
+
lockWhere: findOptions.where,
|
|
55
|
+
ifMatch: ctx.get('If-Match') || undefined,
|
|
51
56
|
});
|
|
57
|
+
setResourceEtagHeader(ctx, ctx.body);
|
|
52
58
|
};
|
|
53
59
|
}
|
|
54
60
|
//# sourceMappingURL=createUpdateAppResourceController.js.map
|
|
@@ -2,4 +2,4 @@ import { type Middleware } from 'koa';
|
|
|
2
2
|
import { type Options } from '../../types.js';
|
|
3
3
|
export declare const bulmaURL: string;
|
|
4
4
|
export declare const faURL: string;
|
|
5
|
-
export declare function createIndexHandler(
|
|
5
|
+
export declare function createIndexHandler(options: Options): Middleware;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { getAppBlocks } from '@appsemble/lang-sdk';
|
|
3
3
|
import { createThemeURL, defaultLocale, mergeThemes } from '@appsemble/utils';
|
|
4
4
|
import { organizationBlocklist } from '../../../organizationBlocklist.js';
|
|
@@ -6,8 +6,50 @@ import { makeCSP, render } from '../../../render.js';
|
|
|
6
6
|
import { bulmaVersion, faVersion } from '../../../versions.js';
|
|
7
7
|
export const bulmaURL = `/bulma/${bulmaVersion}/bulma.min.css`;
|
|
8
8
|
export const faURL = `/fa/${faVersion}/css/all.min.css`;
|
|
9
|
-
|
|
9
|
+
function hash(value) {
|
|
10
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('base64url');
|
|
11
|
+
}
|
|
12
|
+
function getAppUpdated(app) {
|
|
13
|
+
return app.$updated ?? '';
|
|
14
|
+
}
|
|
15
|
+
function getAppSnapshotId(app) {
|
|
16
|
+
return app.version ?? 'none';
|
|
17
|
+
}
|
|
18
|
+
function getSettingsCacheKey({ app, host, hostname, identifiableBlocks, languages, }) {
|
|
19
|
+
const blocks = identifiableBlocks
|
|
20
|
+
.map(({ type, version }) => ({ type, version }))
|
|
21
|
+
.sort((a, b) => a.type.localeCompare(b.type) || a.version.localeCompare(b.version));
|
|
22
|
+
return [
|
|
23
|
+
'app-settings',
|
|
24
|
+
app.id,
|
|
25
|
+
getAppUpdated(app),
|
|
26
|
+
getAppSnapshotId(app),
|
|
27
|
+
hash({ host, hostname }),
|
|
28
|
+
hash(languages),
|
|
29
|
+
hash(blocks),
|
|
30
|
+
].join(':');
|
|
31
|
+
}
|
|
32
|
+
function getMessagesCacheKey({ app }) {
|
|
33
|
+
return ['app-messages', app.id, getAppUpdated(app)].join(':');
|
|
34
|
+
}
|
|
35
|
+
async function getCachedValue(cache, key, fallback) {
|
|
36
|
+
if (!cache) {
|
|
37
|
+
return [await fallback(), 'disabled'];
|
|
38
|
+
}
|
|
39
|
+
const cached = await cache.get(key);
|
|
40
|
+
if (cached.status === 'hit') {
|
|
41
|
+
return [cached.value, 'hit'];
|
|
42
|
+
}
|
|
43
|
+
const value = await fallback();
|
|
44
|
+
if (cached.status === 'miss') {
|
|
45
|
+
const setStatus = await cache.set(key, value);
|
|
46
|
+
return [value, setStatus === 'error' ? 'error' : 'miss'];
|
|
47
|
+
}
|
|
48
|
+
return [value, cached.status];
|
|
49
|
+
}
|
|
50
|
+
export function createIndexHandler(options) {
|
|
10
51
|
return async (ctx) => {
|
|
52
|
+
const { appServingCache, createSettings, getApp, getAppDetails, getAppMessages, getAppUrl, getCsp, getHost, } = options;
|
|
11
53
|
const { hostname, path } = ctx;
|
|
12
54
|
const host = getHost({ context: ctx });
|
|
13
55
|
// Prevent mime-type sniffing,
|
|
@@ -40,21 +82,26 @@ export function createIndexHandler({ createSettings, getApp, getAppDetails, getA
|
|
|
40
82
|
return;
|
|
41
83
|
}
|
|
42
84
|
const defaultLanguage = app.definition.defaultLanguage || defaultLocale;
|
|
43
|
-
const appMessages = await getAppMessages({ app, context: ctx });
|
|
85
|
+
const [appMessages, messagesCacheStatus] = await getCachedValue(appServingCache, getMessagesCacheKey({ app }), () => getAppMessages({ app, context: ctx }));
|
|
86
|
+
ctx.set('X-Appsemble-Messages-Cache', messagesCacheStatus);
|
|
44
87
|
const languages = [
|
|
45
88
|
...new Set([...appMessages.map(({ language }) => language), defaultLanguage]),
|
|
46
89
|
].sort();
|
|
47
90
|
const identifiableBlocks = getAppBlocks(app.definition);
|
|
48
91
|
const nonce = randomBytes(16).toString('base64');
|
|
49
|
-
const [settingsHash, settings] = await
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
92
|
+
const [{ settingsHash, settings }, settingsCacheStatus] = await getCachedValue(appServingCache, getSettingsCacheKey({ app, host, hostname, identifiableBlocks, languages }), async () => {
|
|
93
|
+
const [digest, script] = await createSettings({
|
|
94
|
+
context: ctx,
|
|
95
|
+
app,
|
|
96
|
+
host,
|
|
97
|
+
hostname,
|
|
98
|
+
identifiableBlocks,
|
|
99
|
+
languages,
|
|
100
|
+
nonce,
|
|
101
|
+
});
|
|
102
|
+
return { settingsHash: digest, settings: script };
|
|
57
103
|
});
|
|
104
|
+
ctx.set('X-Appsemble-Settings-Cache', settingsCacheStatus);
|
|
58
105
|
const csp = getCsp({ app, settingsHash, hostname, host, nonce });
|
|
59
106
|
ctx.set('Content-Security-Policy', makeCSP(csp));
|
|
60
107
|
const updated = app.$updated ? new Date(app.$updated) : new Date();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
export function createServiceWorkerHandler() {
|
|
3
4
|
return async (ctx) => {
|
|
@@ -6,8 +7,18 @@ export function createServiceWorkerHandler() {
|
|
|
6
7
|
const serviceWorker = await (production
|
|
7
8
|
? readFile(new URL('../../../../../dist/app/service-worker.js', import.meta.url), 'utf8')
|
|
8
9
|
: ctx.fs.promises.readFile(filename, 'utf8'));
|
|
9
|
-
ctx.body = serviceWorker;
|
|
10
10
|
ctx.type = 'application/javascript';
|
|
11
|
+
// Browsers bypass their HTTP cache when checking for service worker updates, so a strong ETag
|
|
12
|
+
// lets them revalidate with a 304 instead of downloading the full script on every check.
|
|
13
|
+
ctx.set('etag', `"${createHash('sha256').update(serviceWorker).digest('base64url')}"`);
|
|
14
|
+
ctx.set('cache-control', 'no-cache');
|
|
15
|
+
ctx.status = 200;
|
|
16
|
+
if (ctx.fresh) {
|
|
17
|
+
ctx.status = 304;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
ctx.body = serviceWorker;
|
|
21
|
+
}
|
|
11
22
|
};
|
|
12
23
|
}
|
|
13
24
|
//# sourceMappingURL=serviceWorkerHandler.js.map
|
package/server/types.d.ts
CHANGED
|
@@ -112,7 +112,7 @@ declare module 'koas-parameters' {
|
|
|
112
112
|
roles?: string;
|
|
113
113
|
includeMessages: boolean;
|
|
114
114
|
demo: boolean;
|
|
115
|
-
selectedGroupId: number;
|
|
115
|
+
selectedGroupId: number[];
|
|
116
116
|
$own: boolean;
|
|
117
117
|
delimiter?: string;
|
|
118
118
|
email: string;
|
|
@@ -249,13 +249,13 @@ export interface CheckUserOrganizationPermissionsParams {
|
|
|
249
249
|
export interface CheckAuthSubjectAppPermissionsParams {
|
|
250
250
|
context: ParameterizedContext<DefaultState, DefaultContextInterface, any>;
|
|
251
251
|
app: App;
|
|
252
|
-
groupId?: number;
|
|
252
|
+
groupId?: number | number[] | null;
|
|
253
253
|
permissions: CustomAppPermission[];
|
|
254
254
|
}
|
|
255
255
|
export interface CheckAppPermissionsParams {
|
|
256
256
|
context: ParameterizedContext<DefaultState, DefaultContextInterface, any>;
|
|
257
257
|
app: App;
|
|
258
|
-
groupId?: number;
|
|
258
|
+
groupId?: number | number[] | null;
|
|
259
259
|
permissions: CustomAppPermission[];
|
|
260
260
|
}
|
|
261
261
|
export interface ReloadUserParams {
|
|
@@ -288,7 +288,7 @@ export interface CreateAppResourcesWithAssetsParams extends GetAppSubEntityParam
|
|
|
288
288
|
preparedAssets: PreparedAsset[];
|
|
289
289
|
resourceType: string;
|
|
290
290
|
options: Options;
|
|
291
|
-
groupId?: number;
|
|
291
|
+
groupId?: number | null;
|
|
292
292
|
}
|
|
293
293
|
export interface UpdateAppResourceParams extends GetAppSubEntityParams {
|
|
294
294
|
id: number | string;
|
|
@@ -298,12 +298,33 @@ export interface UpdateAppResourceParams extends GetAppSubEntityParams {
|
|
|
298
298
|
deletedAssetIds: string[];
|
|
299
299
|
type: string;
|
|
300
300
|
options: Options;
|
|
301
|
+
/**
|
|
302
|
+
* The same where clause used by the controller's pre-lock fetch. Re-applied
|
|
303
|
+
* inside the SELECT FOR UPDATE so the lock cannot widen the row set (e.g.
|
|
304
|
+
* picking up an expired row or one whose GroupId or seed flag changed
|
|
305
|
+
* between the unlocked read and the lock).
|
|
306
|
+
*/
|
|
307
|
+
lockWhere: WhereOptions;
|
|
308
|
+
/**
|
|
309
|
+
* The `If-Match` header value parsed out of the request, if any.
|
|
310
|
+
*/
|
|
311
|
+
ifMatch?: string;
|
|
301
312
|
}
|
|
302
313
|
export interface DeleteAppResourceParams extends GetAppSubEntityParams {
|
|
303
314
|
id: number | string;
|
|
304
315
|
type: string;
|
|
305
316
|
whereOptions?: WhereOptions;
|
|
306
317
|
options: Options;
|
|
318
|
+
/**
|
|
319
|
+
* Where-clause used by the controller's pre-lock fetch. Re-applied inside
|
|
320
|
+
* the lock so a concurrent writer cannot widen the row set between the read
|
|
321
|
+
* and the delete.
|
|
322
|
+
*/
|
|
323
|
+
lockWhere?: WhereOptions;
|
|
324
|
+
/**
|
|
325
|
+
* The `If-Match` header value parsed out of the request, if any.
|
|
326
|
+
*/
|
|
327
|
+
ifMatch?: string;
|
|
307
328
|
}
|
|
308
329
|
export interface CreateAppAssetParams extends GetAppSubEntityParams {
|
|
309
330
|
payload: {
|
|
@@ -383,6 +404,15 @@ export interface ParsedQuery {
|
|
|
383
404
|
where: WhereOptions;
|
|
384
405
|
}
|
|
385
406
|
export type ContentSecurityPolicy = Record<string, (string | false)[]>;
|
|
407
|
+
export type AppServingCacheStatus = 'disabled' | 'error' | 'hit' | 'miss';
|
|
408
|
+
export interface AppServingCacheResult<T> {
|
|
409
|
+
status: AppServingCacheStatus;
|
|
410
|
+
value?: T;
|
|
411
|
+
}
|
|
412
|
+
export interface AppServingCache {
|
|
413
|
+
get: <T>(key: string) => Promise<AppServingCacheResult<T>>;
|
|
414
|
+
set: <T>(key: string, value: T) => Promise<AppServingCacheStatus>;
|
|
415
|
+
}
|
|
386
416
|
export interface Options {
|
|
387
417
|
getSecurityEmail: () => string;
|
|
388
418
|
getCurrentAppMember: (params: GetCurrentAppMemberParams) => Promise<AppMemberInfo | null>;
|
|
@@ -402,16 +432,18 @@ export interface Options {
|
|
|
402
432
|
getBlockMessages: (params: GetBlockMessagesParams) => Promise<BlockMessages[]>;
|
|
403
433
|
getBlockAsset: (params: GetBlockAssetParams) => Promise<ProjectAsset>;
|
|
404
434
|
getBlocksAssetsPaths: (params: GetBlocksAssetsPathsParams) => Promise<string[]>;
|
|
405
|
-
getTheme: (params: GetThemeParams) => Promise<Theme>;
|
|
435
|
+
getTheme: (params: GetThemeParams) => Promise<Theme | null>;
|
|
406
436
|
createTheme: (params: CreateThemeParams) => Promise<Theme>;
|
|
407
437
|
getHost: (params: GetHostParams) => string;
|
|
408
438
|
getCsp: (params: GetCspParams) => ContentSecurityPolicy;
|
|
409
439
|
createSettings: (params: CreateSettingsParams) => Promise<[digest: string, script: string]>;
|
|
440
|
+
appServingCache?: AppServingCache;
|
|
410
441
|
applyAppServiceSecrets: (params: ApplyAppServiceSecretsParams) => Promise<RawAxiosRequestConfig<any>>;
|
|
411
442
|
checkAppMemberAppPermissions: (params: CheckAppMemberAppPermissionsParams) => Promise<void>;
|
|
412
443
|
checkUserOrganizationPermissions: (params: CheckUserOrganizationPermissionsParams) => Promise<void>;
|
|
413
444
|
checkAuthSubjectAppPermissions: (params: CheckAuthSubjectAppPermissionsParams) => Promise<void>;
|
|
414
445
|
checkAppPermissions: (params: CheckAppPermissionsParams) => Promise<void>;
|
|
446
|
+
getAllowedGroups: (params: CheckAppPermissionsParams) => Promise<number[]>;
|
|
415
447
|
reloadUser: (params: ReloadUserParams) => Promise<Record<string, any>>;
|
|
416
448
|
parseQuery: (params: ParseQueryParams) => ParsedQuery;
|
|
417
449
|
getAppResource: (params: GetAppResourceParams) => Promise<Resource | null>;
|
package/server/utils/actions.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defaultLocale, remap, } from '@appsemble/lang-sdk';
|
|
2
2
|
import { assertKoaCondition, createFormData, EmailQuotaExceededError, getContainerNamespace, getRemapperContext, getSSRFProtectedAgents, logger, parseServiceUrl, scaleDeployment, setLastRequestAnnotation, throwKoaError, version, waitForPodReadiness, } from '@appsemble/node-utils';
|
|
3
|
+
import { deserializeResource } from '@appsemble/utils';
|
|
3
4
|
import axios from 'axios';
|
|
4
|
-
import { get,
|
|
5
|
+
import { get, pick } from 'lodash-es';
|
|
5
6
|
/**
|
|
6
7
|
* These response headers are forwarded when proxying requests.
|
|
7
8
|
*/
|
|
@@ -40,26 +41,6 @@ export async function handleNotify(ctx, app, action, options) {
|
|
|
40
41
|
await sendNotifications({ app, to, title, body, link });
|
|
41
42
|
ctx.status = 204;
|
|
42
43
|
}
|
|
43
|
-
function deserializeResource(data) {
|
|
44
|
-
// Extract the resource and assets from the JSON object
|
|
45
|
-
const { resource } = data;
|
|
46
|
-
const assets = data.assets;
|
|
47
|
-
// Function to replace asset placeholders with actual Blobs
|
|
48
|
-
const replaceAssets = (value) => {
|
|
49
|
-
if (Array.isArray(value)) {
|
|
50
|
-
return value.map(replaceAssets);
|
|
51
|
-
}
|
|
52
|
-
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
|
53
|
-
return assets[Number(value)];
|
|
54
|
-
}
|
|
55
|
-
if (value && typeof value === 'object') {
|
|
56
|
-
return mapValues(value, replaceAssets);
|
|
57
|
-
}
|
|
58
|
-
return value;
|
|
59
|
-
};
|
|
60
|
-
// Replace placeholders and return the deserialized resource
|
|
61
|
-
return replaceAssets(resource);
|
|
62
|
-
}
|
|
63
44
|
async function handleRequestProxy(ctx, app, action, useBody, options) {
|
|
64
45
|
const { method, query, request: { body, headers }, } = ctx;
|
|
65
46
|
let data;
|
|
@@ -2,6 +2,30 @@ import { type ResourceDefinition } from '@appsemble/lang-sdk';
|
|
|
2
2
|
import { type App } from '@appsemble/types';
|
|
3
3
|
import { type Context } from 'koa';
|
|
4
4
|
import { type Options, type OrderItem, type WhereOptions } from '../types.js';
|
|
5
|
+
/**
|
|
6
|
+
* The `selectedGroupId` value representing the app-wide (ungrouped) scope, as
|
|
7
|
+
* opposed to a concrete group id.
|
|
8
|
+
*/
|
|
9
|
+
export declare const appWideGroupId = -1;
|
|
10
|
+
/**
|
|
11
|
+
* Collapse a `selectedGroupId` selection to a single group for operations that
|
|
12
|
+
* are inherently scoped to one group (create, update, reorder).
|
|
13
|
+
*
|
|
14
|
+
* @param selectedGroupId The selected group ids from the query parameters.
|
|
15
|
+
* @returns The first concrete group id, or null for the app-wide scope.
|
|
16
|
+
*/
|
|
17
|
+
export declare function getSingleGroupId(selectedGroupId?: number[]): number | null;
|
|
18
|
+
/**
|
|
19
|
+
* Build a resource `GroupId` filter for operations that may span multiple
|
|
20
|
+
* groups (query, delete).
|
|
21
|
+
*
|
|
22
|
+
* The app-wide scope (`appWideGroupId`, or an empty selection) matches
|
|
23
|
+
* resources without a group; concrete ids match resources in those groups.
|
|
24
|
+
*
|
|
25
|
+
* @param selectedGroupId The selected group ids from the query parameters.
|
|
26
|
+
* @returns A value for a resource `GroupId` where clause.
|
|
27
|
+
*/
|
|
28
|
+
export declare function getGroupIdWhere(selectedGroupId?: number[]): WhereOptions[string];
|
|
5
29
|
export declare function generateResourceQuery(ctx: Context, { parseQuery }: Options, resourceDefinition?: ResourceDefinition): {
|
|
6
30
|
order: OrderItem[];
|
|
7
31
|
where: WhereOptions;
|
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
import { throwKoaError } from '../../koa.js';
|
|
2
2
|
import { logger } from '../../logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* The `selectedGroupId` value representing the app-wide (ungrouped) scope, as
|
|
5
|
+
* opposed to a concrete group id.
|
|
6
|
+
*/
|
|
7
|
+
export const appWideGroupId = -1;
|
|
8
|
+
/**
|
|
9
|
+
* Collapse a `selectedGroupId` selection to a single group for operations that
|
|
10
|
+
* are inherently scoped to one group (create, update, reorder).
|
|
11
|
+
*
|
|
12
|
+
* @param selectedGroupId The selected group ids from the query parameters.
|
|
13
|
+
* @returns The first concrete group id, or null for the app-wide scope.
|
|
14
|
+
*/
|
|
15
|
+
export function getSingleGroupId(selectedGroupId = []) {
|
|
16
|
+
const [groupId] = selectedGroupId;
|
|
17
|
+
return groupId != null && groupId > 0 ? groupId : null;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build a resource `GroupId` filter for operations that may span multiple
|
|
21
|
+
* groups (query, delete).
|
|
22
|
+
*
|
|
23
|
+
* The app-wide scope (`appWideGroupId`, or an empty selection) matches
|
|
24
|
+
* resources without a group; concrete ids match resources in those groups.
|
|
25
|
+
*
|
|
26
|
+
* @param selectedGroupId The selected group ids from the query parameters.
|
|
27
|
+
* @returns A value for a resource `GroupId` where clause.
|
|
28
|
+
*/
|
|
29
|
+
export function getGroupIdWhere(selectedGroupId = []) {
|
|
30
|
+
const groupIds = selectedGroupId.filter((id) => id > 0);
|
|
31
|
+
const includeUngrouped = selectedGroupId.length === 0 || selectedGroupId.includes(appWideGroupId);
|
|
32
|
+
if (!groupIds.length) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return includeUngrouped ? { or: [{ in: groupIds }, null] } : { in: groupIds };
|
|
36
|
+
}
|
|
3
37
|
export function generateResourceQuery(ctx, { parseQuery }, resourceDefinition) {
|
|
4
38
|
try {
|
|
5
39
|
return parseQuery({
|