adapt-authoring-jsonschema 1.1.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/JsonSchemaModule.js +138 -113
- package/package.json +3 -22
- package/lib/JsonSchema.js +0 -273
- package/lib/Keywords.js +0 -74
- package/lib/XSSDefaults.js +0 -142
- package/schema/base.schema.json +0 -13
package/lib/JsonSchemaModule.js
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { AbstractModule, Hook } from 'adapt-authoring-core'
|
|
3
|
-
import Ajv from 'ajv/dist/2020.js'
|
|
1
|
+
import { AbstractModule, App, Hook } from 'adapt-authoring-core'
|
|
4
2
|
import { glob } from 'glob'
|
|
5
|
-
import JsonSchema from './JsonSchema.js'
|
|
6
|
-
import Keywords from './Keywords.js'
|
|
7
3
|
import path from 'path'
|
|
8
|
-
import
|
|
9
|
-
import XSSDefaults from './XSSDefaults.js'
|
|
4
|
+
import { Schemas, SchemaError, XSSDefaults } from 'adapt-schemas'
|
|
10
5
|
|
|
11
|
-
const BASE_SCHEMA_PATH = './schema/base.schema.json'
|
|
12
6
|
/**
|
|
13
|
-
* Module which
|
|
7
|
+
* Module which adds support for the JSON Schema specification.
|
|
8
|
+
* This is a thin wrapper around the adapt-schemas library providing
|
|
9
|
+
* Adapt framework integration (hooks, logging, config, errors).
|
|
14
10
|
* @memberof jsonschema
|
|
15
11
|
* @extends {AbstractModule}
|
|
16
12
|
*/
|
|
@@ -18,99 +14,141 @@ class JsonSchemaModule extends AbstractModule {
|
|
|
18
14
|
/** @override */
|
|
19
15
|
async init () {
|
|
20
16
|
this.app.jsonschema = this
|
|
21
|
-
/**
|
|
22
|
-
* Reference to all registed schemas
|
|
23
|
-
* @type {Object}
|
|
24
|
-
*/
|
|
25
|
-
this.schemas = {}
|
|
26
|
-
/**
|
|
27
|
-
* Temporary store of extension schemas
|
|
28
|
-
* @type {Object}
|
|
29
|
-
*/
|
|
30
|
-
this.schemaExtensions = {}
|
|
31
17
|
/**
|
|
32
18
|
* Invoked when schemas are registered
|
|
33
19
|
* @type {Hook}
|
|
34
20
|
*/
|
|
35
21
|
this.registerSchemasHook = new Hook()
|
|
36
22
|
/**
|
|
37
|
-
*
|
|
38
|
-
* @type {
|
|
39
|
-
*/
|
|
40
|
-
this.xssWhitelist = {}
|
|
41
|
-
/**
|
|
42
|
-
* Reference to the Ajv instance
|
|
43
|
-
* @type {external:Ajv}
|
|
23
|
+
* Internal schema library instance
|
|
24
|
+
* @type {Schemas}
|
|
44
25
|
*/
|
|
45
|
-
this.
|
|
46
|
-
|
|
47
|
-
allErrors: true,
|
|
48
|
-
allowUnionTypes: true,
|
|
49
|
-
loadSchema: this.getSchema.bind(this),
|
|
50
|
-
removeAdditional: 'all',
|
|
51
|
-
strict: false,
|
|
52
|
-
verbose: true,
|
|
53
|
-
keywords: Keywords.all
|
|
26
|
+
this._library = new Schemas({
|
|
27
|
+
enableCache: true // Will be overridden from config when ready
|
|
54
28
|
})
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
29
|
+
// Forward library events to module logging
|
|
30
|
+
this._library.on('warning', msg => this.log('warn', msg))
|
|
31
|
+
this._library.on('schemaRegistered', (name, filePath) => this.log('verbose', 'REGISTER_SCHEMA', name, filePath))
|
|
32
|
+
this._library.on('schemaDeregistered', name => this.log('debug', 'DEREGISTER_SCHEMA', name))
|
|
33
|
+
this._library.on('schemaExtended', (base, ext) => this.log('verbose', 'EXTEND_SCHEMA', base, ext))
|
|
34
|
+
this._library.on('reset', () => this.log('debug', 'RESET_SCHEMAS'))
|
|
35
|
+
|
|
36
|
+
await this._library.init()
|
|
37
|
+
|
|
38
|
+
this._library.addKeyword({
|
|
39
|
+
keyword: 'isDirectory',
|
|
40
|
+
type: 'string',
|
|
41
|
+
modifying: true,
|
|
42
|
+
schemaType: 'boolean',
|
|
43
|
+
compile: function () {
|
|
44
|
+
const doReplace = value => {
|
|
45
|
+
const app = App.instance
|
|
46
|
+
return [
|
|
47
|
+
['$ROOT', app.rootDir],
|
|
48
|
+
['$DATA', app.getConfig('dataDir')],
|
|
49
|
+
['$TEMP', app.getConfig('tempDir')]
|
|
50
|
+
].reduce((m, [k, v]) => {
|
|
51
|
+
return m.startsWith(k) ? path.resolve(v, m.replace(k, '').slice(1)) : m
|
|
52
|
+
}, value)
|
|
53
|
+
}
|
|
54
|
+
return (value, { parentData, parentDataProperty }) => {
|
|
55
|
+
try {
|
|
56
|
+
parentData[parentDataProperty] = doReplace(value)
|
|
57
|
+
} catch (e) {}
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
59
61
|
})
|
|
60
|
-
await this.resetSchemaRegistry()
|
|
61
62
|
|
|
62
63
|
this.onReady()
|
|
63
64
|
.then(() => this.app.waitForModule('config', 'errors'))
|
|
64
65
|
.then(() => {
|
|
65
|
-
|
|
66
|
+
// Update library options from config
|
|
67
|
+
this._library.options.enableCache = this.getConfig('enableCache')
|
|
68
|
+
|
|
69
|
+
// Update XSS whitelist
|
|
70
|
+
Object.assign(
|
|
71
|
+
this._library.xssWhitelist,
|
|
66
72
|
this.getConfig('xssWhitelistOverride') ? {} : XSSDefaults,
|
|
67
|
-
this.getConfig('xssWhitelist')
|
|
73
|
+
this.getConfig('xssWhitelist')
|
|
74
|
+
)
|
|
68
75
|
})
|
|
69
|
-
.then(() =>
|
|
70
|
-
|
|
76
|
+
.then(() => {
|
|
77
|
+
// Add format overrides from config
|
|
78
|
+
const formatOverrides = this.getConfig('formatOverrides')
|
|
79
|
+
if (formatOverrides) {
|
|
80
|
+
this._library.addStringFormats(formatOverrides)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
.then(() => this.registerSchemas({ quiet: true }))
|
|
71
84
|
.catch(e => this.log('error', e))
|
|
72
85
|
|
|
73
86
|
this.app.onReady()
|
|
74
87
|
.then(() => this.logSchemas())
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Reference to all registered schemas
|
|
92
|
+
* @type {Object}
|
|
93
|
+
*/
|
|
94
|
+
get schemas () {
|
|
95
|
+
return this._library.schemas
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Temporary store of extension schemas
|
|
100
|
+
* @type {Object}
|
|
101
|
+
*/
|
|
102
|
+
get schemaExtensions () {
|
|
103
|
+
return this._library.schemaExtensions
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Tags and attributes to be whitelisted by the XSS filter
|
|
108
|
+
* @type {Object}
|
|
109
|
+
*/
|
|
110
|
+
get xssWhitelist () {
|
|
111
|
+
return this._library.xssWhitelist
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reference to the Ajv instance
|
|
116
|
+
* @type {external:Ajv}
|
|
117
|
+
*/
|
|
118
|
+
get validator () {
|
|
119
|
+
return this._library.validator
|
|
120
|
+
}
|
|
121
|
+
|
|
77
122
|
/**
|
|
78
123
|
* Empties the schema registry (with the exception of the base schema)
|
|
79
124
|
*/
|
|
80
125
|
async resetSchemaRegistry () {
|
|
81
|
-
this.
|
|
82
|
-
this.schemas = {
|
|
83
|
-
base: await this.createSchema(path.resolve(this.rootDir, BASE_SCHEMA_PATH), { enableCache: true })
|
|
84
|
-
}
|
|
126
|
+
await this._library.resetSchemaRegistry()
|
|
85
127
|
}
|
|
86
128
|
|
|
87
129
|
/**
|
|
88
130
|
* Adds string formats to the Ajv validator
|
|
131
|
+
* @param {Object} formats Object mapping format names to RegExp patterns
|
|
89
132
|
*/
|
|
90
133
|
addStringFormats (formats) {
|
|
91
|
-
|
|
92
|
-
const isUnsafe = !safeRegex(re)
|
|
93
|
-
if (isUnsafe) this.log('warn', `unsafe RegExp for format '${name}' (${re}), using default`)
|
|
94
|
-
this.validator.addFormat(name, isUnsafe ? /.*/ : re)
|
|
95
|
-
})
|
|
134
|
+
this._library.addStringFormats(formats)
|
|
96
135
|
}
|
|
97
136
|
|
|
98
137
|
/**
|
|
99
138
|
* Adds a new keyword to be used in JSON schemas
|
|
100
|
-
* @param {
|
|
139
|
+
* @param {Object} definition AJV keyword definition
|
|
140
|
+
* @param {Object} options Configuration options
|
|
141
|
+
* @param {Boolean} options.override Whether to override an existing definition
|
|
101
142
|
*/
|
|
102
|
-
addKeyword (definition) {
|
|
103
|
-
|
|
104
|
-
this.validator.addKeyword(definition)
|
|
105
|
-
} catch (e) {
|
|
106
|
-
this.log('warn', `failed to define keyword '${definition.keyword}', ${e}`)
|
|
107
|
-
}
|
|
143
|
+
addKeyword (definition, options) {
|
|
144
|
+
this._library.addKeyword(definition, options)
|
|
108
145
|
}
|
|
109
146
|
|
|
110
147
|
/**
|
|
111
|
-
* Searches all Adapt dependencies for any local JSON schemas and registers them for use in the app.
|
|
112
|
-
*
|
|
113
|
-
* @param
|
|
148
|
+
* Searches all Adapt dependencies for any local JSON schemas and registers them for use in the app.
|
|
149
|
+
* Schemas must be located in a `/schema` folder, and be named appropriately: `*.schema.json`.
|
|
150
|
+
* @param {Object} options
|
|
151
|
+
* @param {Boolean} options.quiet Set to true to suppress logs
|
|
114
152
|
* @return {Promise}
|
|
115
153
|
*/
|
|
116
154
|
async registerSchemas (options = {}) {
|
|
@@ -118,7 +156,8 @@ class JsonSchemaModule extends AbstractModule {
|
|
|
118
156
|
await Promise.all(Object.values(this.app.dependencies).map(async d => {
|
|
119
157
|
if (d.name === this.name) return
|
|
120
158
|
const files = await glob('schema/*.schema.json', { cwd: d.rootDir, absolute: true })
|
|
121
|
-
|
|
159
|
+
const results = await Promise.allSettled(files.map(f => this.registerSchema(f)))
|
|
160
|
+
results
|
|
122
161
|
.filter(r => r.status === 'rejected')
|
|
123
162
|
.forEach(r => this.log('warn', r.reason))
|
|
124
163
|
}))
|
|
@@ -129,57 +168,44 @@ class JsonSchemaModule extends AbstractModule {
|
|
|
129
168
|
/**
|
|
130
169
|
* Registers a single JSON schema for use in the app
|
|
131
170
|
* @param {String} filePath Path to the schema file
|
|
132
|
-
* @param {
|
|
133
|
-
* @
|
|
171
|
+
* @param {Object} options Extra options
|
|
172
|
+
* @param {Boolean} options.replace Replace existing schema with same name
|
|
173
|
+
* @return {Promise<Schema>}
|
|
134
174
|
*/
|
|
135
175
|
async registerSchema (filePath, options = {}) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
176
|
+
try {
|
|
177
|
+
return await this._library.registerSchema(filePath, options)
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Convert library errors to app errors
|
|
180
|
+
if (e instanceof SchemaError) {
|
|
181
|
+
const appError = this.app.errors[e.code]
|
|
182
|
+
if (appError) {
|
|
183
|
+
throw appError.setData(e.data)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
throw e
|
|
144
187
|
}
|
|
145
|
-
this.schemas[schema.name] = schema
|
|
146
|
-
this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
|
|
147
|
-
if (schema.raw.$patch) this.extendSchema(schema.raw.$patch?.source?.$ref, schema.name)
|
|
148
|
-
|
|
149
|
-
this.log('verbose', 'REGISTER_SCHEMA', schema.name, filePath)
|
|
150
188
|
}
|
|
151
189
|
|
|
152
190
|
/**
|
|
153
|
-
*
|
|
154
|
-
* @param {String} name
|
|
155
|
-
* @return {Promise} Resolves with schema data
|
|
191
|
+
* Deregisters a single JSON schema
|
|
192
|
+
* @param {String} name Schema name to deregister
|
|
156
193
|
*/
|
|
157
194
|
deregisterSchema (name) {
|
|
158
|
-
|
|
159
|
-
// remove schema from any extensions lists
|
|
160
|
-
Object.entries(this.schemaExtensions).forEach(([base, extensions]) => {
|
|
161
|
-
this.schemaExtensions[base] = extensions.filter(s => s !== name)
|
|
162
|
-
})
|
|
163
|
-
this.log('debug', 'DEREGISTER_SCHEMA', name)
|
|
195
|
+
this._library.deregisterSchema(name)
|
|
164
196
|
}
|
|
165
197
|
|
|
166
198
|
/**
|
|
167
|
-
* Creates a new
|
|
199
|
+
* Creates a new Schema instance
|
|
168
200
|
* @param {String} filePath Path to the schema file
|
|
169
|
-
* @param {Object} options Options passed to
|
|
170
|
-
* @returns {
|
|
201
|
+
* @param {Object} options Options passed to Schema constructor
|
|
202
|
+
* @returns {Promise<Schema>}
|
|
171
203
|
*/
|
|
172
|
-
createSchema (filePath, options) {
|
|
173
|
-
|
|
204
|
+
createSchema (filePath, options = {}) {
|
|
205
|
+
return this._library.createSchema(filePath, {
|
|
174
206
|
enableCache: this.getConfig('enableCache'),
|
|
175
|
-
filePath,
|
|
176
|
-
validator: this.validator,
|
|
177
|
-
xssWhitelist: this.xssWhitelist,
|
|
178
207
|
...options
|
|
179
208
|
})
|
|
180
|
-
this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
|
|
181
|
-
delete this.schemaExtensions?.[schema.name]
|
|
182
|
-
return schema.load()
|
|
183
209
|
}
|
|
184
210
|
|
|
185
211
|
/**
|
|
@@ -188,27 +214,26 @@ class JsonSchemaModule extends AbstractModule {
|
|
|
188
214
|
* @param {String} extSchemaName The name of the schema to extend with
|
|
189
215
|
*/
|
|
190
216
|
extendSchema (baseSchemaName, extSchemaName) {
|
|
191
|
-
|
|
192
|
-
if (baseSchema) {
|
|
193
|
-
baseSchema.addExtension(extSchemaName)
|
|
194
|
-
} else {
|
|
195
|
-
if (!this.schemaExtensions[baseSchemaName]) this.schemaExtensions[baseSchemaName] = []
|
|
196
|
-
this.schemaExtensions[baseSchemaName].push(extSchemaName)
|
|
197
|
-
}
|
|
198
|
-
this.log('verbose', 'EXTEND_SCHEMA', baseSchemaName, extSchemaName)
|
|
217
|
+
this._library.extendSchema(baseSchemaName, extSchemaName)
|
|
199
218
|
}
|
|
200
219
|
|
|
201
220
|
/**
|
|
202
|
-
* Retrieves the specified schema. Recursively applies any schema merge/patch schemas.
|
|
221
|
+
* Retrieves the specified schema. Recursively applies any schema merge/patch schemas.
|
|
222
|
+
* Will return cached data if enabled.
|
|
203
223
|
* @param {String} schemaName The name of the schema to return
|
|
204
|
-
* @param {
|
|
224
|
+
* @param {Object} options
|
|
205
225
|
* @param {Boolean} options.compiled If false, the raw schema will be returned
|
|
206
|
-
* @return {Promise} The
|
|
226
|
+
* @return {Promise<Schema>} The schema instance
|
|
207
227
|
*/
|
|
208
228
|
async getSchema (schemaName, options = {}) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
229
|
+
try {
|
|
230
|
+
return await this._library.getSchema(schemaName, options)
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e instanceof SchemaError && e.code === 'MISSING_SCHEMA') {
|
|
233
|
+
throw this.app.errors.MISSING_SCHEMA.setData({ schemaName })
|
|
234
|
+
}
|
|
235
|
+
throw e
|
|
236
|
+
}
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-jsonschema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Module to add support for the JSON schema specification",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-jsonschema",
|
|
6
6
|
"license": "GPL-3.0",
|
|
@@ -11,26 +11,15 @@
|
|
|
11
11
|
},
|
|
12
12
|
"repository": "github:adapt-security/adapt-authoring-jsonschema",
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"
|
|
15
|
-
"bytes": "^3.1.2",
|
|
16
|
-
"glob": "^13.0.0",
|
|
17
|
-
"lodash": "^4.17.21",
|
|
18
|
-
"ms": "^2.1.3",
|
|
19
|
-
"safe-regex": "2.1.1",
|
|
20
|
-
"xss": "^1.0.14"
|
|
14
|
+
"adapt-schemas": "^1.0.0"
|
|
21
15
|
},
|
|
22
16
|
"peerDependencies": {
|
|
23
17
|
"adapt-authoring-core": "github:adapt-security/adapt-authoring-core"
|
|
24
18
|
},
|
|
25
19
|
"devDependencies": {
|
|
26
|
-
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
27
20
|
"@semantic-release/git": "^10.0.1",
|
|
28
|
-
"@semantic-release/github": "^12.0.2",
|
|
29
|
-
"@semantic-release/npm": "^13.1.2",
|
|
30
|
-
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
31
21
|
"conventional-changelog-eslint": "^6.0.0",
|
|
32
22
|
"semantic-release": "^25.0.2",
|
|
33
|
-
"semantic-release-replace-plugin": "^1.2.7",
|
|
34
23
|
"standard": "^17.1.0"
|
|
35
24
|
},
|
|
36
25
|
"release": {
|
|
@@ -49,15 +38,7 @@
|
|
|
49
38
|
],
|
|
50
39
|
"@semantic-release/npm",
|
|
51
40
|
"@semantic-release/github",
|
|
52
|
-
|
|
53
|
-
"@semantic-release/git",
|
|
54
|
-
{
|
|
55
|
-
"assets": [
|
|
56
|
-
"package.json"
|
|
57
|
-
],
|
|
58
|
-
"message": "Chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
|
59
|
-
}
|
|
60
|
-
]
|
|
41
|
+
"@semantic-release/git"
|
|
61
42
|
]
|
|
62
43
|
}
|
|
63
44
|
}
|
package/lib/JsonSchema.js
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import _ from 'lodash'
|
|
2
|
-
import { App, Hook } from 'adapt-authoring-core'
|
|
3
|
-
import fs from 'fs/promises'
|
|
4
|
-
import xss from 'xss'
|
|
5
|
-
|
|
6
|
-
/** @ignore */ const BASE_SCHEMA_NAME = 'base'
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Functionality related to JSON schema
|
|
10
|
-
* @memberof jsonschema
|
|
11
|
-
*/
|
|
12
|
-
class JsonSchema {
|
|
13
|
-
constructor ({ enableCache, filePath, validator, xssWhitelist }) {
|
|
14
|
-
/**
|
|
15
|
-
* The raw built JSON schema
|
|
16
|
-
* @type {Object}
|
|
17
|
-
*/
|
|
18
|
-
this.built = undefined
|
|
19
|
-
/**
|
|
20
|
-
* The compiled schema validation function
|
|
21
|
-
* @type {function}
|
|
22
|
-
*/
|
|
23
|
-
this.compiled = undefined
|
|
24
|
-
/**
|
|
25
|
-
* Whether caching is enabled for this schema
|
|
26
|
-
* @type {Boolean}
|
|
27
|
-
*/
|
|
28
|
-
this.enableCache = enableCache
|
|
29
|
-
/**
|
|
30
|
-
* List of extensions for this schema
|
|
31
|
-
* @type {Array<String>}
|
|
32
|
-
*/
|
|
33
|
-
this.extensions = []
|
|
34
|
-
/**
|
|
35
|
-
* File path to the schema
|
|
36
|
-
* @type {String}
|
|
37
|
-
*/
|
|
38
|
-
this.filePath = filePath
|
|
39
|
-
/**
|
|
40
|
-
* Whether the schema is currently building
|
|
41
|
-
* @type {Boolean}
|
|
42
|
-
*/
|
|
43
|
-
this.isBuilding = false
|
|
44
|
-
/**
|
|
45
|
-
* The last build time (in milliseconds)
|
|
46
|
-
* @type {Number}
|
|
47
|
-
*/
|
|
48
|
-
this.lastBuildTime = undefined
|
|
49
|
-
/**
|
|
50
|
-
* The raw schema data for this schema (with no inheritance/extensions)
|
|
51
|
-
* @type {Object}
|
|
52
|
-
*/
|
|
53
|
-
this.raw = undefined
|
|
54
|
-
/**
|
|
55
|
-
* Reference to the Ajv validator instance
|
|
56
|
-
* @type {external:Ajv}
|
|
57
|
-
*/
|
|
58
|
-
this.validator = validator
|
|
59
|
-
/**
|
|
60
|
-
* Reference to the local XSS sanitiser instance
|
|
61
|
-
* @type {Object}
|
|
62
|
-
*/
|
|
63
|
-
this.xss = new xss.FilterXSS({ whiteList: xssWhitelist })
|
|
64
|
-
/**
|
|
65
|
-
* Hook which invokes every time the schema is built
|
|
66
|
-
* @type {Hook}
|
|
67
|
-
*/
|
|
68
|
-
this.buildHook = new Hook()
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Determines whether the current schema build is valid using last modification timestamp
|
|
73
|
-
* @returns {Boolean}
|
|
74
|
-
*/
|
|
75
|
-
async isBuildValid () {
|
|
76
|
-
if (!this.built) return false
|
|
77
|
-
let schema = this
|
|
78
|
-
while (schema) {
|
|
79
|
-
const { mtimeMs } = await fs.stat(schema.filePath)
|
|
80
|
-
if (mtimeMs > this.lastBuildTime) return false
|
|
81
|
-
schema = await schema.getParent()
|
|
82
|
-
}
|
|
83
|
-
return true
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Returs the parent schema if $merge is defined (or the base schema if a root schema)
|
|
88
|
-
* @returns {JsonSchema}
|
|
89
|
-
*/
|
|
90
|
-
async getParent () {
|
|
91
|
-
if (this.name === BASE_SCHEMA_NAME) return // base schema always the root
|
|
92
|
-
const jsonschema = await App.instance.waitForModule('jsonschema')
|
|
93
|
-
return await jsonschema.getSchema(this.raw?.$merge?.source?.$ref ?? BASE_SCHEMA_NAME)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Loads the schema file
|
|
98
|
-
* @returns {JsonSchema} This instance
|
|
99
|
-
*/
|
|
100
|
-
async load () {
|
|
101
|
-
try {
|
|
102
|
-
this.raw = JSON.parse((await fs.readFile(this.filePath)).toString())
|
|
103
|
-
this.name = this.raw.$anchor
|
|
104
|
-
} catch (e) {
|
|
105
|
-
throw App.instance.errors?.SCHEMA_LOAD_FAILED?.setData({ schemaName: this.filePath }) ?? e
|
|
106
|
-
}
|
|
107
|
-
if (this.validator.validateSchema(this.raw)?.errors) {
|
|
108
|
-
const errors = this.validator.errors.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
|
|
109
|
-
if (errors.length) {
|
|
110
|
-
throw App.instance.errors.INVALID_SCHEMA
|
|
111
|
-
.setData({ schemaName: this.name, errors: errors.join(', ') })
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return this
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Builds and compiles the schema from the $merge and $patch schemas
|
|
119
|
-
* @param {LoadSchemaOptions}
|
|
120
|
-
* @return {JsonSchema}
|
|
121
|
-
*/
|
|
122
|
-
async build (options = {}) {
|
|
123
|
-
if (options.useCache !== false && this.enableCache && await this.isBuildValid()) {
|
|
124
|
-
return this
|
|
125
|
-
}
|
|
126
|
-
if (this.isBuilding) {
|
|
127
|
-
return new Promise(resolve => this.buildHook.tap(() => resolve(this)))
|
|
128
|
-
}
|
|
129
|
-
this.isBuilding = true
|
|
130
|
-
|
|
131
|
-
const jsonschema = await App.instance.waitForModule('jsonschema')
|
|
132
|
-
const { applyExtensions, extensionFilter } = options
|
|
133
|
-
|
|
134
|
-
let built = _.cloneDeep(this.raw)
|
|
135
|
-
let parent = await this.getParent()
|
|
136
|
-
|
|
137
|
-
while (parent) {
|
|
138
|
-
const parentBuilt = _.cloneDeep((await parent.build({ ...options, compile: false })).built)
|
|
139
|
-
built = await this.patch(parentBuilt, built, { strict: !parent.name === BASE_SCHEMA_NAME })
|
|
140
|
-
parent = await parent.getParent()
|
|
141
|
-
}
|
|
142
|
-
if (this.extensions.length) {
|
|
143
|
-
await Promise.all(this.extensions.map(async s => {
|
|
144
|
-
const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
|
|
145
|
-
if (applyPatch) {
|
|
146
|
-
const extSchema = await jsonschema.getSchema(s)
|
|
147
|
-
this.patch(built, extSchema.raw, { extendAnnotations: false })
|
|
148
|
-
}
|
|
149
|
-
}))
|
|
150
|
-
}
|
|
151
|
-
this.built = built
|
|
152
|
-
if (options.compile !== false) { // don't compile when option present (e.g. when running build recursively)
|
|
153
|
-
this.compiled = await this.validator.compileAsync(built)
|
|
154
|
-
}
|
|
155
|
-
this.isBuilding = false
|
|
156
|
-
this.lastBuildTime = Date.now()
|
|
157
|
-
|
|
158
|
-
this.buildHook.invoke(this)
|
|
159
|
-
return this
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Applies a patch schema to another schema
|
|
164
|
-
* @param {Object} baseSchema The base schema to apply the patch
|
|
165
|
-
* @param {Object} patchSchema The patch schema to apply to the base
|
|
166
|
-
* @param {ApplyPatchOptions} options
|
|
167
|
-
* @return {Object} The base schema
|
|
168
|
-
*/
|
|
169
|
-
patch (baseSchema, patchSchema, options = {}) {
|
|
170
|
-
const opts = _.defaults(options, {
|
|
171
|
-
extendAnnotations: patchSchema.$anchor !== BASE_SCHEMA_NAME,
|
|
172
|
-
overwriteProperties: true,
|
|
173
|
-
strict: true
|
|
174
|
-
})
|
|
175
|
-
const patchData = patchSchema.$patch?.with ?? patchSchema.$merge?.with ?? (!opts.strict && patchSchema)
|
|
176
|
-
if (!patchData) {
|
|
177
|
-
throw App.instance.errors.INVALID_SCHEMA.setData({ schemaName: patchSchema.$anchor })
|
|
178
|
-
}
|
|
179
|
-
if (opts.extendAnnotations) {
|
|
180
|
-
['$anchor', 'title', 'description'].forEach(p => {
|
|
181
|
-
if (patchSchema[p]) baseSchema[p] = patchSchema[p]
|
|
182
|
-
})
|
|
183
|
-
}
|
|
184
|
-
if (patchData.properties) {
|
|
185
|
-
const mergeFunc = opts.overwriteProperties ? _.merge : _.defaultsDeep
|
|
186
|
-
mergeFunc(baseSchema.properties, patchData.properties)
|
|
187
|
-
}
|
|
188
|
-
['allOf', 'anyOf', 'oneOf'].forEach(p => {
|
|
189
|
-
if (patchData[p]?.length) baseSchema[p] = (baseSchema[p] ?? []).concat(_.cloneDeep(patchData[p]))
|
|
190
|
-
})
|
|
191
|
-
if (patchData.required) {
|
|
192
|
-
baseSchema.required = _.uniq([...(baseSchema.required ?? []), ...patchData.required])
|
|
193
|
-
}
|
|
194
|
-
return baseSchema
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Checks passed data against the specified schema (if it exists)
|
|
199
|
-
* @param {Object} dataToValidate The data to be validated
|
|
200
|
-
* @param {SchemaValidateOptions} options
|
|
201
|
-
* @return {Object} The validated data
|
|
202
|
-
*/
|
|
203
|
-
validate (dataToValidate, options) {
|
|
204
|
-
const opts = _.defaults(options, { useDefaults: true, ignoreRequired: false })
|
|
205
|
-
const data = _.defaults(_.cloneDeep(dataToValidate), opts.useDefaults ? this.getObjectDefaults() : {})
|
|
206
|
-
if (!this.compiled) { // fallback in the case that the compiled function is missing
|
|
207
|
-
this.log('warn', 'NO_COMPILED_FUNC', this.name)
|
|
208
|
-
this.validator.compile(this.built)
|
|
209
|
-
}
|
|
210
|
-
this.compiled(data)
|
|
211
|
-
|
|
212
|
-
const errors = this.compiled.errors && this.compiled.errors
|
|
213
|
-
.filter(e => e.keyword === 'required' ? !opts.ignoreRequired : true)
|
|
214
|
-
.map(e => e.instancePath ? `${e.instancePath} ${e.message}` : e.message)
|
|
215
|
-
.reduce((s, e) => `${s}${e}, `, '')
|
|
216
|
-
|
|
217
|
-
if (errors?.length) { throw App.instance.errors.VALIDATION_FAILED.setData({ schemaName: this.name, errors, data }) }
|
|
218
|
-
|
|
219
|
-
return data
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Sanitises data by removing attributes according to the context (provided by options)
|
|
224
|
-
* @param {Object} dataToValidate The data to be sanitised
|
|
225
|
-
* @param {SchemaSanitiseOptions} options
|
|
226
|
-
* @return {Object} The sanitised data
|
|
227
|
-
*/
|
|
228
|
-
sanitise (dataToSanitise, options = {}, schema) {
|
|
229
|
-
const opts = _.defaults(options, { isInternal: false, isReadOnly: false, sanitiseHtml: true, strict: true })
|
|
230
|
-
schema = schema ?? this.built
|
|
231
|
-
const sanitised = {}
|
|
232
|
-
for (const prop in schema.properties) {
|
|
233
|
-
const schemaData = schema.properties[prop]
|
|
234
|
-
const value = dataToSanitise[prop]
|
|
235
|
-
const ignore = (opts.isInternal && schemaData.isInternal) || (opts.isReadOnly && schemaData.isReadOnly)
|
|
236
|
-
if (value === undefined || (ignore && !opts.strict)) {
|
|
237
|
-
continue
|
|
238
|
-
}
|
|
239
|
-
if (ignore && opts.strict) {
|
|
240
|
-
throw App.instance.errors.MODIFY_PROTECTED_ATTR.setData({ attribute: prop, value })
|
|
241
|
-
}
|
|
242
|
-
sanitised[prop] =
|
|
243
|
-
schemaData.type === 'object' && schemaData.properties
|
|
244
|
-
? this.sanitise(value, opts, schemaData)
|
|
245
|
-
: schemaData.type === 'string' && opts.sanitiseHtml
|
|
246
|
-
? this.xss.process(value)
|
|
247
|
-
: value
|
|
248
|
-
}
|
|
249
|
-
return sanitised
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Adds an extension schema
|
|
254
|
-
* @param {String} extSchemaName
|
|
255
|
-
*/
|
|
256
|
-
addExtension (extSchemaName) {
|
|
257
|
-
!this.extensions.includes(extSchemaName) && this.extensions.push(extSchemaName)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Returns all schema defaults as a correctly structured object
|
|
262
|
-
* @param {Object} schema
|
|
263
|
-
* @param {Object} memo For recursion
|
|
264
|
-
* @returns {Object} The defaults object
|
|
265
|
-
*/
|
|
266
|
-
getObjectDefaults (schema) {
|
|
267
|
-
schema = schema ?? this.built
|
|
268
|
-
const props = schema.properties ?? schema.$merge?.with?.properties ?? schema.$patch?.with?.properties
|
|
269
|
-
return _.mapValues(props, s => s.type === 'object' && s.properties ? this.getObjectDefaults(s) : s.default)
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export default JsonSchema
|
package/lib/Keywords.js
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { App } from 'adapt-authoring-core'
|
|
2
|
-
import bytes from 'bytes'
|
|
3
|
-
import ms from 'ms'
|
|
4
|
-
import path from 'path'
|
|
5
|
-
/**
|
|
6
|
-
* Adds some useful schema keywords
|
|
7
|
-
* @memberof jsonschema
|
|
8
|
-
* @extends {AbstractModule}
|
|
9
|
-
*/
|
|
10
|
-
class Keywords {
|
|
11
|
-
static get all () {
|
|
12
|
-
const keywords = {
|
|
13
|
-
isBytes: function () {
|
|
14
|
-
return (value, { parentData, parentDataProperty }) => {
|
|
15
|
-
try {
|
|
16
|
-
parentData[parentDataProperty] = bytes.parse(value)
|
|
17
|
-
return true
|
|
18
|
-
} catch (e) {
|
|
19
|
-
return false
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
isDate: function () {
|
|
24
|
-
return (value, { parentData, parentDataProperty }) => {
|
|
25
|
-
try {
|
|
26
|
-
parentData[parentDataProperty] = new Date(value)
|
|
27
|
-
return true
|
|
28
|
-
} catch (e) {
|
|
29
|
-
return false
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
},
|
|
33
|
-
isDirectory: function () {
|
|
34
|
-
const doReplace = value => {
|
|
35
|
-
const app = App.instance
|
|
36
|
-
return [
|
|
37
|
-
['$ROOT', app.rootDir],
|
|
38
|
-
['$DATA', app.getConfig('dataDir')],
|
|
39
|
-
['$TEMP', app.getConfig('tempDir')]
|
|
40
|
-
].reduce((m, [k, v]) => {
|
|
41
|
-
return m.startsWith(k) ? path.resolve(v, m.replace(k, '').slice(1)) : m
|
|
42
|
-
}, value)
|
|
43
|
-
}
|
|
44
|
-
return (value, { parentData, parentDataProperty }) => {
|
|
45
|
-
try {
|
|
46
|
-
parentData[parentDataProperty] = doReplace(value)
|
|
47
|
-
} catch (e) {}
|
|
48
|
-
return true
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
isTimeMs: function () {
|
|
52
|
-
return (value, { parentData, parentDataProperty }) => {
|
|
53
|
-
try {
|
|
54
|
-
parentData[parentDataProperty] = ms(value)
|
|
55
|
-
return true
|
|
56
|
-
} catch (e) {
|
|
57
|
-
return false
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return Object.entries(keywords).map(([keyword, compile]) => {
|
|
63
|
-
return {
|
|
64
|
-
keyword,
|
|
65
|
-
type: 'string',
|
|
66
|
-
modifying: true,
|
|
67
|
-
schemaType: 'boolean',
|
|
68
|
-
compile
|
|
69
|
-
}
|
|
70
|
-
})
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export default Keywords
|
package/lib/XSSDefaults.js
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
export default {
|
|
2
|
-
a: ['class', 'href', 'rel', 'target', 'title'],
|
|
3
|
-
abbr: ['title'],
|
|
4
|
-
address: [],
|
|
5
|
-
area: ['alt', 'coords', 'href', 'shape'],
|
|
6
|
-
article: [],
|
|
7
|
-
aside: ['aria-hidden', 'class', 'role'],
|
|
8
|
-
audio: [
|
|
9
|
-
'autoplay',
|
|
10
|
-
'controls',
|
|
11
|
-
'crossorigin',
|
|
12
|
-
'loop',
|
|
13
|
-
'muted',
|
|
14
|
-
'preload',
|
|
15
|
-
'src'
|
|
16
|
-
],
|
|
17
|
-
b: [],
|
|
18
|
-
bdi: ['dir'],
|
|
19
|
-
bdo: ['dir'],
|
|
20
|
-
big: [],
|
|
21
|
-
blockquote: ['cite'],
|
|
22
|
-
br: [],
|
|
23
|
-
button: ['class'],
|
|
24
|
-
caption: [],
|
|
25
|
-
center: [],
|
|
26
|
-
cite: [],
|
|
27
|
-
code: [],
|
|
28
|
-
col: ['align', 'span', 'valign', 'width'],
|
|
29
|
-
colgroup: ['align', 'span', 'valign', 'width'],
|
|
30
|
-
data: [],
|
|
31
|
-
dd: [],
|
|
32
|
-
del: ['datetime'],
|
|
33
|
-
dfn: [],
|
|
34
|
-
details: ['open'],
|
|
35
|
-
div: [
|
|
36
|
-
'aria-describedby',
|
|
37
|
-
'aria-description',
|
|
38
|
-
'aria-label',
|
|
39
|
-
'aria-hidden',
|
|
40
|
-
'class',
|
|
41
|
-
'role',
|
|
42
|
-
'tabindex'
|
|
43
|
-
],
|
|
44
|
-
dl: [],
|
|
45
|
-
dt: [],
|
|
46
|
-
em: [],
|
|
47
|
-
figcaption: [],
|
|
48
|
-
figure: ['class'],
|
|
49
|
-
font: ['color', 'face', 'size'],
|
|
50
|
-
footer: [],
|
|
51
|
-
h1: ['class'],
|
|
52
|
-
h2: ['class'],
|
|
53
|
-
h3: ['class'],
|
|
54
|
-
h4: ['class'],
|
|
55
|
-
h5: ['class'],
|
|
56
|
-
h6: ['class'],
|
|
57
|
-
header: [],
|
|
58
|
-
hr: [],
|
|
59
|
-
i: [],
|
|
60
|
-
img: [
|
|
61
|
-
'alt',
|
|
62
|
-
'aria-hidden',
|
|
63
|
-
'aria-label',
|
|
64
|
-
'class',
|
|
65
|
-
'height',
|
|
66
|
-
'loading',
|
|
67
|
-
'src',
|
|
68
|
-
'title',
|
|
69
|
-
'width'
|
|
70
|
-
],
|
|
71
|
-
ins: ['datetime'],
|
|
72
|
-
kbd: [],
|
|
73
|
-
li: ['class'],
|
|
74
|
-
mark: [],
|
|
75
|
-
math: [],
|
|
76
|
-
mfrac: [],
|
|
77
|
-
mi: [],
|
|
78
|
-
mn: [],
|
|
79
|
-
mo: [],
|
|
80
|
-
mover: [],
|
|
81
|
-
mrow: [],
|
|
82
|
-
ms: [],
|
|
83
|
-
mspace: [],
|
|
84
|
-
msub: [],
|
|
85
|
-
msubsup: [],
|
|
86
|
-
msup: [],
|
|
87
|
-
mtext: [],
|
|
88
|
-
munder: [],
|
|
89
|
-
munderover: [],
|
|
90
|
-
nav: [],
|
|
91
|
-
ol: ['class'],
|
|
92
|
-
p: ['lang'],
|
|
93
|
-
pre: [],
|
|
94
|
-
q: [],
|
|
95
|
-
rp: [],
|
|
96
|
-
rt: [],
|
|
97
|
-
ruby: [],
|
|
98
|
-
s: [],
|
|
99
|
-
samp: [],
|
|
100
|
-
section: [],
|
|
101
|
-
small: [],
|
|
102
|
-
span: [
|
|
103
|
-
'aria-describedby',
|
|
104
|
-
'aria-description',
|
|
105
|
-
'aria-label',
|
|
106
|
-
'aria-hidden',
|
|
107
|
-
'class',
|
|
108
|
-
'role',
|
|
109
|
-
'tabindex'
|
|
110
|
-
],
|
|
111
|
-
sub: [],
|
|
112
|
-
summary: [],
|
|
113
|
-
sup: [],
|
|
114
|
-
strong: [],
|
|
115
|
-
strike: [],
|
|
116
|
-
table: ['align', 'border', 'width', 'valign'],
|
|
117
|
-
tbody: ['align', 'valign'],
|
|
118
|
-
td: ['align', 'colspan', 'rowspan', 'valign', 'width'],
|
|
119
|
-
tfoot: ['align', 'valign'],
|
|
120
|
-
th: ['align', 'colspan', 'rowspan', 'valign', 'width'],
|
|
121
|
-
thead: ['align', 'valign'],
|
|
122
|
-
time: [],
|
|
123
|
-
tr: ['align', 'rowspan', 'valign'],
|
|
124
|
-
tt: [],
|
|
125
|
-
u: [],
|
|
126
|
-
ul: ['class'],
|
|
127
|
-
var: [],
|
|
128
|
-
video: [
|
|
129
|
-
'autoplay',
|
|
130
|
-
'controls',
|
|
131
|
-
'crossorigin',
|
|
132
|
-
'loop',
|
|
133
|
-
'muted',
|
|
134
|
-
'playsinline',
|
|
135
|
-
'poster',
|
|
136
|
-
'preload',
|
|
137
|
-
'src',
|
|
138
|
-
'height',
|
|
139
|
-
'width'
|
|
140
|
-
],
|
|
141
|
-
wbr: []
|
|
142
|
-
}
|
package/schema/base.schema.json
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
-
"$anchor": "base",
|
|
4
|
-
"description": "The base schema inherited by all other schemas",
|
|
5
|
-
"type": "object",
|
|
6
|
-
"properties": {
|
|
7
|
-
"_id": {
|
|
8
|
-
"description": "Unique identifier",
|
|
9
|
-
"type": "string",
|
|
10
|
-
"isObjectId": true
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
}
|